diff --git a/.editorconfig b/.editorconfig index 7add053..bfa467e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,5 +10,9 @@ max_line_length = 120 [*.py] indent_size = 4 +[*.rs] +indent_size = 4 +max_line_length = off + [{CLAUDE.md,.editorconfig,super-linter.env,lychee.toml,renovate.json5,default.json,mise.toml}] max_line_length = 300 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/agents/knowledge/README.md b/.github/agents/knowledge/README.md new file mode 100644 index 0000000..978136e --- /dev/null +++ b/.github/agents/knowledge/README.md @@ -0,0 +1,14 @@ +# Knowledge Index + +Reusable repository guidance for agents working on flint v2. + +Load only files relevant to the current scope. + +## Topics + +| File | Load when | +| ----------------- | --------------------------------------------------------------------------------- | +| `architecture.md` | Navigating the codebase; understanding module roles or check kinds | +| `linters.md` | Adding, modifying, or debugging a linter; `registry.rs` changes; config injection | +| `design.md` | Questioning why something works the way it does; avoiding known pitfalls | +| `testing.md` | Writing or updating tests; adding fixture cases; regenerating snapshots | diff --git a/.github/agents/knowledge/architecture.md b/.github/agents/knowledge/architecture.md new file mode 100644 index 0000000..6248d42 --- /dev/null +++ b/.github/agents/knowledge/architecture.md @@ -0,0 +1,38 @@ +# Architecture + +## Module Map + +- **`src/registry.rs`**: Static linter registry. Defines + `Check` (builder pattern) and `builtin()` which returns + the full list of built-in checks. This is where new + linters are added. +- **`src/runner.rs`**: Executes checks against a file list. + Handles parallel execution (check mode) and serial + execution (fix mode, to avoid concurrent writes). +- **`src/config.rs`**: Loads `flint.toml` from the project + root. All fields have defaults — the file is optional. +- **`src/files.rs`**: Git-aware file discovery. Returns + changed files relative to the merge base, or all files + with `--full`. +- **`src/linters/`**: Custom logic for special checks that + can't be expressed as a simple command template: + - `lychee.rs`: Link checking orchestration + - `renovate_deps.rs`: Renovate snapshot verification +- **`src/main.rs`**: CLI parsing (clap), orchestration, + output formatting. +- **`tests/e2e.rs`**: End-to-end tests. Spin up a temp git + repo, write files, run the flint binary, assert on + stdout/stderr and exit code. + +## Check Kinds + +A `Check` is either a `Template` (a command string with +`{FILE}`, `{FILES}`, or `{MERGE_BASE}` placeholders) or a +`Special` (custom Rust logic in `src/linters/`). + +Template scopes: + +- `File` — invoked once per matched file (`{FILE}`) +- `Files` — invoked once with all matched files (`{FILES}`) +- `Project` — invoked once with no file args; skipped + entirely if no matching files changed diff --git a/.github/agents/knowledge/design.md b/.github/agents/knowledge/design.md new file mode 100644 index 0000000..e5568ad --- /dev/null +++ b/.github/agents/knowledge/design.md @@ -0,0 +1,48 @@ +# Key Design Decisions + +1. **Activation via `mise.toml`**: A check is active when + its tool (or `mise_tool_name` override) is declared in + the consuming repo's `mise.toml`. No PATH probing — + mise guarantees declared tools are on PATH. + +2. **`editorconfig-checker` deference**: `editorconfig-checker` + (binary: `ec`) runs on all files but skips file types owned + by active line-length-enforcing formatters (`cargo-fmt`, + `ruff-format`, `biome-format`, `prettier`). Implemented + via `.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 } + ``` + +4. **Fix mode runs serially**: `runner.rs` runs checks in + parallel in check mode, but serially in fix mode to + avoid concurrent writes to the same file. + +5. **Version ranges**: When a `bin_name` has any + `version_range` entries, every entry for that binary + must have one (enforced by a registry unit test). This + prevents ambiguous activation when ranges don't cover + all versions. + +6. **Special checks**: `links` and `renovate-deps` have + custom orchestration logic that doesn't fit the command + template model. Their implementations live in + `src/linters/`. + +7. **Built-in file exclusions**: `src/files.rs` has a + `BUILTIN_EXCLUDES` slice of paths that are always removed + from the file list before any linter sees it. Currently + contains `.github/renovate-tracked-deps.json` (a + generated file that should never be linted by prettier, + ec, etc.). Add entries here — not in user-facing `exclude` + docs — when a file is managed by flint itself. diff --git a/.github/agents/knowledge/linters.md b/.github/agents/knowledge/linters.md new file mode 100644 index 0000000..ae139fb --- /dev/null +++ b/.github/agents/knowledge/linters.md @@ -0,0 +1,80 @@ +# Adding a New Linter + +Add an entry to `builtin()` in `src/registry.rs` using the +builder pattern: + +```rust +// File scope — invoked per file +Check::file("mytool", "mytool --check {FILE}", &["*.ext"]) + .fix("mytool --fix {FILE}"), + +// Files scope — invoked once with all matched files (absolute paths) +Check::files("mytool", "mytool {FILES}", &["*.ext"]) + .fix("mytool --fix {FILES}"), + +// Files scope — invoked once with all matched files (relative to project root) +// Use {RELFILES} when the tool requires paths relative to the project root +// (e.g. dotnet format --include). +Check::files("mytool", "mytool --include {RELFILES}", &["*.ext"]) + .fix("mytool --fix --include {RELFILES}"), + +// Project scope — invoked once, skipped if no *.ext changed +Check::project("mytool", "mytool run", &["*.ext"]), +``` + +Available builder modifiers: + +| Method | Purpose | +| ---------------------------- | ----------------------------------------------------------------------------- | +| `.fix(cmd)` | Enable `--fix` mode with this command | +| `.bin(name)` | Override binary name (when check name ≠ binary) | +| `.mise_tool(name)` | Look up availability under a different mise key (e.g. `rust` for `cargo-fmt`) | +| `.version_req(range)` | Restrict to a semver range (e.g. `">=1.0.0"`) | +| `.excludes(names)` | Skip files already owned by these active checks | +| `.slow()` | Mark as slow — skipped by `--fast-only` | +| `.linter_config(file, flag)` | Inject a config flag when `FLINT_CONFIG_DIR/` exists (see below) | + +## Config File Injection (`.linter_config`) + +Use `.linter_config(filename, flag)` when the tool supports an explicit config +file path via a CLI flag. At runtime, if `FLINT_CONFIG_DIR/` exists, +flint injects `flag ` right after the binary name in the command. +If the file is absent the flag is silently omitted — native config discovery +remains in effect. + +```rust +// Example: markdownlint accepts --config +Check::file("markdownlint", "markdownlint {FILE}", &["*.md"]) + .fix("markdownlint --fix {FILE}") + .linter_config(".markdownlint.json", "--config"), +// → markdownlint --config /repo/.github/config/.markdownlint.json +``` + +**When NOT to use it:** + +- The tool has no explicit `--config`/`--rcfile`/equivalent flag (e.g. `shfmt`) +- The flag accepts a **directory** rather than a file (e.g. biome's + `--config-path `) — a different injection shape is needed. For biome, + check for `biome.json` existence but pass `config_dir` itself as the arg: + `biome --config-path check `. This requires a variant of + `.linter_config` that injects the directory rather than the full file path + (not yet implemented) +- The tool is project-scoped and its config must live at the project root to + function (no explicit `--config` flag exists) + +Look up the tool's `--help` or man page for the config flag name and expected +argument type before adding `.linter_config`. + +For checks that need custom logic (not a simple command template), add a module +under `src/linters/` and use `CheckKind::Special`. + +## Changed-files scoping + +Most linters use `file` or `files` scope, so they naturally receive only changed +files as arguments. `golangci-lint` uses `project` scope but scopes internally via +`--new-from-rev={MERGE_BASE}`. + +**`cargo-clippy` cannot scope to changed files.** Cargo has no git-aware flag +equivalent to `--new-from-rev`. It still skips entirely when no `*.rs` files +changed, but when it does run it checks the whole project. Workspace support +(`-p --no-deps` per changed package) would be a future improvement. diff --git a/.github/agents/knowledge/testing.md b/.github/agents/knowledge/testing.md new file mode 100644 index 0000000..f5c90e7 --- /dev/null +++ b/.github/agents/knowledge/testing.md @@ -0,0 +1,60 @@ +# Testing + +Run all tests with: + +```bash +cargo test +``` + +## Unit Tests + +In-module `#[cfg(test)]` blocks in `src/`. Notable: + +- `src/registry.rs`: enforces version-range consistency +- `src/runner.rs`: config injection, scope filtering +- `src/linters/renovate_deps.rs`: log parsing, snapshot + read/write, diff output + +## Fixture-based E2E Tests + +`tests/cases/` holds one directory per scenario. Each +contains: + +- `files/` — files copied verbatim into a temp git repo + and staged before the run +- `test.toml` — test spec: + +```toml +[expected] +args = "--full shellcheck" +exit = 1 # optional, default 0 +stderr = """ +...golden output... +""" + +[expected.files] # optional: assert files written by --fix +".github/renovate-tracked-deps.json" = """ +{...} +""" + +[env] # optional extra env vars +FOO = "bar" + +[fake_bins] # optional fake binaries (Unix only) +renovate = ''' +#!/bin/sh +echo '...' +''' +``` + +The `cases` test in `tests/e2e.rs` runs all of them. +Set `UPDATE_SNAPSHOTS=1` to regenerate `[expected].exit`/ +`stderr`/`stdout` in place. `[expected.files]` and `[fake_bins]` +are always preserved by the snapshot writer. + +Use fixture cases for any check — including ones that require +fake external binaries (via `[fake_bins]`). The fixture runner +writes each binary into a tempdir and prepends it to `PATH`. + +When adding a new check, cover at least: clean pass, failure +with correct diff/output, and fix mode if supported. diff --git a/.github/config/flint.toml b/.github/config/flint.toml new file mode 100644 index 0000000..b3f85fc --- /dev/null +++ b/.github/config/flint.toml @@ -0,0 +1,5 @@ +[settings] +exclude = ["CHANGELOG.md", "tests/cases/**"] + +[checks.renovate-deps] +exclude_managers = ["github-actions", "github-runners", "cargo"] diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 212d103..c579bc9 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -4,19 +4,40 @@ "mise" ] }, - "README.md": { + ".github/workflows/test.yml": { "regex": [ - "grafana/flint" + "mise" ] }, "mise.toml": { "mise": [ + "actionlint", + "cargo:xmloxide", + "dotnet", + "editorconfig-checker", + "github:google/google-java-format", + "github:koalaman/shellcheck", + "github:mvdan/sh", + "github:pinterest/ktlint", + "go", + "golangci-lint", + "hadolint", "lychee", "node", - "npm:renovate" - ], + "npm:@biomejs/biome", + "npm:markdownlint-cli2", + "npm:prettier", + "npm:renovate", + "pipx:codespell", + "pipx:ruff", + "rust" + ] + }, + "src/init/generation.rs": { "regex": [ - "ghcr.io/super-linter/super-linter" + "actions/checkout", + "jdx/mise-action", + "mise" ] } } diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 90c7bf0..2ba5ba0 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -5,7 +5,7 @@ dependencyDashboard: true, platformCommit: "enabled", automerge: true, - ignorePaths: [], + ignorePaths: ["tests/"], ignorePresets: [":ignoreModulesAndTests"], ignoreUnstable: true, vulnerabilityAlerts: { @@ -38,6 +38,33 @@ depNameTemplate: "mise", matchStrings: ["jdx/mise-action.*\\n\\s*with:\\s*\\n\\s*version: [\"']?(?v[.\\d]+)[\"']?\\s*\\n\\s*sha256: [\"']?(?\\w+)[\"']?"], }, + { + customType: "regex", + description: "Update GitHub Action SHA pins embedded in the generated workflow template (src/init/generation.rs)", + managerFilePatterns: ["/^src/init/generation\\.rs$/"], + matchStrings: ["uses: (?[^@\\\\]+)@(?[a-f0-9]{40})\\s*#\\s*(?v[^\\s'\"\\\\]+)"], + datasourceTemplate: "github-tags", + }, + { + customType: "regex", + description: "Update mise per-platform sha256 hashes in test.yml matrix", + managerFilePatterns: ["/.github/workflows/test.yml/"], + datasourceTemplate: "github-release-attachments", + packageNameTemplate: "jdx/mise", + depNameTemplate: "mise", + matchStrings: [ + "mise_version: [\"']?(?v[.\\d]+)[\"']?\\s*\\n\\s*mise_sha256: [\"']?(?[a-f0-9]{64})[\"']?", + ], + }, + { + customType: "regex", + description: "Update mise version embedded in the generated workflow template (src/init/generation.rs)", + managerFilePatterns: ["/^src/init/generation\\.rs$/"], + datasourceTemplate: "github-release-attachments", + packageNameTemplate: "jdx/mise", + depNameTemplate: "mise", + matchStrings: ["jdx/mise-action.*\\n\\s*with:\\s*\\n\\s*version: [\"']?(?v[.\\d]+)[\"']?\\s*\\n\\s*sha256: [\"']?(?\\w+)[\"']?"], + }, ], packageRules: [ { diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 62ff2f1..101e60e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,8 @@ name: Lint on: + push: + branches: [main] # warms the Rust cache so PR branches get a cache hit pull_request: permissions: {} @@ -26,6 +28,13 @@ jobs: version: v2026.4.1 sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd + # mise may activate an existing Rust toolchain without adding missing components. + - name: Install Rust lint components + run: rustup component add clippy rustfmt + + - name: Restore cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Lint env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a92bb35 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +--- +name: Release + +on: + push: + tags: + - "v*" + +permissions: {} + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: + contents: write + id-token: write + attestations: write + + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-24.04 + - target: aarch64-unknown-linux-gnu + runner: ubuntu-24.04 + build-tool: cross + - target: x86_64-apple-darwin + runner: macos-15-intel + - target: aarch64-apple-darwin + runner: macos-latest + - target: x86_64-pc-windows-msvc + runner: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Build and upload + uses: taiki-e/upload-rust-binary-action@10c1cf6a3da113ad4e60018e386570529aa5f1d3 # v1.30.0 + with: + bin: flint + target: ${{ matrix.target }} + archive: flint-$target + build-tool: ${{ matrix.build-tool }} + tar: unix + zip: windows + checksum: sha256 + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Attest build provenance + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: flint-${{ matrix.target }}.* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..023cbba --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,52 @@ +--- +name: Test + +on: + push: + branches: [main] + pull_request: + +permissions: {} + +jobs: + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + mise_version: v2026.4.1 + mise_sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd + - os: macos-15 + mise_version: v2026.4.1 + mise_sha256: c85b387148d478dec754ded31d01798e2f4e4e9448f75682dcc6bb7c16c9a4f5 + - os: windows-2025 + mise_version: v2026.4.1 + mise_sha256: "" # not published for .exe — https://github.com/jdx/mise/pull/8997 + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 # needed for merge-base in e2e tests + + - name: Setup mise + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + with: + version: ${{ matrix.mise_version }} + sha256: ${{ matrix.mise_sha256 }} + + - name: Install Rust lint components + run: rustup component add clippy rustfmt + + - name: Restore cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + + - name: Test + run: cargo test diff --git a/.gitignore b/.gitignore index 1933a30..549fd40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea .mise.super-linter-*.toml +/target diff --git a/AGENTS-V1.md b/AGENTS-V1.md new file mode 100644 index 0000000..e414eab --- /dev/null +++ b/AGENTS-V1.md @@ -0,0 +1,158 @@ +# AGENTS-V1.md + +Guidance for working on flint v1 — the bash task scripts. +For v2 (Rust binary), see [AGENTS-V2.md](AGENTS-V2.md). + +## Repository Overview + +The v1 scripts live under `tasks/lint/`. They are designed to +be consumed as HTTP remote tasks in other repositories' +`mise.toml` files, not run directly in this repository. + +## Architecture + +### Task Script Design Pattern + +All task scripts follow these conventions: + +- **Environment**: Scripts expect `MISE_PROJECT_ROOT` to be + set (automatically provided by mise) +- **Metadata**: Shell scripts use `#MISE` comments for + metadata; Python scripts use `# [MISE]` comments +- **Usage args**: Shell scripts use `#USAGE` comments to + define CLI arguments that mise parses +- **Exit behavior**: Scripts exit with non-zero on errors + for CI integration +- **AUTOFIX mode**: All lint scripts check the `AUTOFIX` + environment variable. When `AUTOFIX=true`, linters that + support fixing issues will automatically apply fixes; + linters without fix capabilities silently ignore it. + This allows consuming repos to run all lints with + `AUTOFIX=true` via a single task (e.g., `mise run fix`) + without needing per-linter configuration + +### Script Categories + +**`tasks/lint/`** - Linting validators: + +- `super-linter.sh`: Runs Super-Linter via Docker/Podman, + auto-detects runtime, handles SELinux on Fedora. + `--native` flag runs a **subset** of linters directly + on the host for fast local feedback (not a full + replacement for the container — CI uses the full set). + `--full` flag lints all files instead of only changed + files (applies to both native and container modes) +- `links.sh`: Runs lychee link checker with two default + checks (all links in modified files + local links in all + files) and a `--full` flag for comprehensive checking +- `renovate-deps.py`: Verifies + `.github/renovate-tracked-deps.json` is up to date by + running Renovate locally and parsing its debug logs. + With `AUTOFIX=true`, automatically regenerates and + updates the file + +### Key Design Decisions + +1. **Container runtime detection**: `super-linter.sh` tries + podman first (with SELinux "z" mount flag), + falls back to Docker. With `--native`, the container + runtime is bypassed entirely and linters run directly + on the host +2. **AUTOFIX mode**: Lint scripts that support fixing accept + `--autofix` flag and `AUTOFIX` env var for unified fix + workflows: + - `super-linter.sh`: Filters out `FIX_*` env vars + unless autofix is enabled + - `renovate-deps.py`: Automatically regenerates and + updates `.github/renovate-tracked-deps.json` + when autofix is enabled + - `links.sh`: Silently ignores the `AUTOFIX` env var + (lychee has no autofix capability; + no `--autofix` flag is exposed) + - The `AUTOFIX` env var is how the `fix` meta-task + propagates autofix through the dependency chain +3. **Diff-based link checking**: `links.sh` runs two checks + by default (all links in modified files + local links in + all files), use `--full` to check all links in all files; + falls back to `--full` when config changes +4. **Renovate exclusions**: `RENOVATE_TRACKED_DEPS_EXCLUDE` + allows skipping managers like + `github-actions,github-runners` +5. **Consuming repos provide config**: Scripts reference + config files (`.github/config/super-linter.env`, + `.github/config/lychee.toml`) that consuming repos + must provide + +## Testing Changes + +Since these are remote task scripts consumed by other repos: + +1. Test changes by pointing a consuming repo's `mise.toml` + to a local file path or Git branch +2. Verify scripts work with both Docker and Podman +3. Test with and without `AUTOFIX=true`: + - `super-linter.sh`: Verify `FIX_*` vars are filtered + correctly + - `renovate-deps.py`: Verify it regenerates and updates + the file + - Link linters: Verify they run normally and don't + output warnings +4. For Renovate scripts, ensure they handle missing deps + gracefully + +## Native Mode Tips + +For faster native linting, consider switching +`super-linter.env` from a deny-list +(`VALIDATE_X=false` for each unwanted linter) to an +allow-list (only `VALIDATE_X=true` for linters you +want). Super-linter's logic — and native mode — treats +any explicit `VALIDATE_*=true` as "only run these". +This avoids noise from linters like `golangci-lint` +running on non-Go repos. + +After updating the super-linter version in `mise.toml`, +run `mise run setup:native-lint-tools` on the host to +install matching tool versions. Native mode fails if +enabled tools are missing. + +**Config files:** Native mode requires linter configs at +standard locations (project root), not in +`.github/linters/` (super-linter's convention). The +script errors if `.github/linters/` exists. All +supported linters auto-discover their config: +`textlint`→`.textlintrc`, +`shellcheck`→`.shellcheckrc`, +`markdownlint`→`.markdownlint.json`, +`ec` (editorconfig-checker)→`.ecrc`, +`actionlint`→`.github/actionlint.yml`, +`hadolint`→`.hadolint.yaml`, +`golangci-lint`→`.golangci.yml`, +`ruff`→`ruff.toml`/`pyproject.toml`, +`codespell`→`.codespellrc`/`pyproject.toml`, +`biome`→`biome.json`, +`prettier`→`.prettierrc`, +`shfmt`→`.editorconfig`. + +## Adding New Linters + +When adding new lint scripts, follow these patterns: + +1. **Add AUTOFIX support**: Check for `AUTOFIX` env var and + implement fix behavior if the underlying tool supports it +2. **Silent fallback**: If the tool doesn't support autofix, + silently ignore `AUTOFIX` (no warnings or errors) +3. **Consistent behavior**: Ensure the script works the same + whether `AUTOFIX` is set or not for check-only tools +4. **Document support**: Update README.md table to show + whether AUTOFIX is supported + +## Script Conventions + +- Shell scripts use `set -euo pipefail` for safety +- Python scripts check for `MISE_PROJECT_ROOT` and exit + with clear error if missing +- Use `# shellcheck disable=` with justification when + intentionally violating shellcheck rules +- Python scripts use `sys.exit(1)` on errors, print errors + to stderr diff --git a/AGENTS-V2.md b/AGENTS-V2.md new file mode 100644 index 0000000..8e541ec --- /dev/null +++ b/AGENTS-V2.md @@ -0,0 +1,45 @@ +# flint v2 + +## Scope + +Guidance for working on flint v2 — the Rust binary. +For v1 (bash task scripts), see [AGENTS-V1.md](AGENTS-V1.md). + +## Repository Layout + +- Usage documentation: `README.md` +- Agent knowledge index: `.github/agents/knowledge/README.md` + +## Repository Overview + +v2 is a single Rust binary (`flint`) that discovers linting +tools from the consuming repo's `mise.toml`, runs them +against changed files in parallel, and produces identical +output locally and in CI. + +## Knowledge Loading + +For coding, fix, and refactoring tasks, consult `.github/agents/knowledge/README.md` +before making substantial changes. + +Use the knowledge index to load only the article(s) relevant to the current task. +Do not load the entire knowledge folder by default. + +## Execution Rules + +Run tests with `cargo test`. Tests spin up temporary git repos and run the real +`flint` binary — they are integration tests, not unit tests, so they can be slow. + +The `cases` test runs all fixture cases under `tests/cases/` in parallel by +top-level directory (linter group). Two env vars control its behaviour: + +- `FLINT_CASES=` — run only cases matching that prefix, e.g. + `FLINT_CASES=shellcheck` or `FLINT_CASES=shellcheck/clean`. +- `UPDATE_SNAPSHOTS=1` — regenerate golden stdout/stderr/exit in `test.toml` + instead of asserting. Always review the diff before committing. + +On failure the test prints a rerun hint, e.g.: +`FLINT_CASES=shellcheck/clean cargo test cases` + +Always run `mise run lint:fix` before committing and review auto-fixed files — +auto-fixes may produce unexpected results. diff --git a/AGENTS.md b/AGENTS.md index 446aac8..ae28cfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,107 +3,18 @@ This file provides guidance to AI coding agents when working with code in this repository. -## Repository Overview +## Versions -This repository contains reusable mise task scripts for -linting. These scripts are designed to be consumed as HTTP -remote tasks in other repositories' `mise.toml` files, not -run directly in this repository. +This repository contains two generations of flint: -## Architecture - -### Task Script Design Pattern - -All task scripts follow these conventions: - -- **Environment**: Scripts expect `MISE_PROJECT_ROOT` to be - set (automatically provided by mise) -- **Metadata**: Shell scripts use `#MISE` comments for - metadata; Python scripts use `# [MISE]` comments -- **Usage args**: Shell scripts use `#USAGE` comments to - define CLI arguments that mise parses -- **Exit behavior**: Scripts exit with non-zero on errors - for CI integration -- **AUTOFIX mode**: All lint scripts check the `AUTOFIX` - environment variable. When `AUTOFIX=true`, linters that - support fixing issues will automatically apply fixes; - linters without fix capabilities silently ignore it. - This allows consuming repos to run all lints with - `AUTOFIX=true` via a single task (e.g., `mise run fix`) - without needing per-linter configuration - -### Script Categories - -**`tasks/lint/`** - Linting validators: - -- `super-linter.sh`: Runs Super-Linter via Docker/Podman, - auto-detects runtime, handles SELinux on Fedora. - `--native` flag runs a **subset** of linters directly - on the host for fast local feedback (not a full - replacement for the container — CI uses the full set). - `--full` flag lints all files instead of only changed - files (applies to both native and container modes) -- `links.sh`: Runs lychee link checker with two default - checks (all links in modified files + local links in all - files) and a `--full` flag for comprehensive checking -- `renovate-deps.py`: Verifies - `.github/renovate-tracked-deps.json` is up to date by - running Renovate locally and parsing its debug logs. - With `AUTOFIX=true`, automatically regenerates and - updates the file - -### Key Design Decisions - -1. **Container runtime detection**: `super-linter.sh` tries - podman first (with SELinux "z" mount flag), - falls back to Docker. With `--native`, the container - runtime is bypassed entirely and linters run directly - on the host -2. **AUTOFIX mode**: Lint scripts that support fixing accept - `--autofix` flag and `AUTOFIX` env var for unified fix - workflows: - - `super-linter.sh`: Filters out `FIX_*` env vars - unless autofix is enabled - - `renovate-deps.py`: Automatically regenerates and - updates `.github/renovate-tracked-deps.json` - when autofix is enabled - - `links.sh`: Silently ignores the `AUTOFIX` env var - (lychee has no autofix capability; - no `--autofix` flag is exposed) - - The `AUTOFIX` env var is how the `fix` meta-task - propagates autofix through the dependency chain -3. **Diff-based link checking**: `links.sh` runs two checks - by default (all links in modified files + local links in - all files), use `--full` to check all links in all files; - falls back to `--full` when config changes -4. **Renovate exclusions**: `RENOVATE_TRACKED_DEPS_EXCLUDE` - allows skipping managers like - `github-actions,github-runners` -5. **Consuming repos provide config**: Scripts reference - config files (`.github/config/super-linter.env`, - `.github/config/lychee.toml`) that consuming repos - must provide - -## Testing Changes - -Since these are remote task scripts consumed by other repos: - -1. Test changes by pointing a consuming repo's `mise.toml` - to a local file path or Git branch -2. Verify scripts work with both Docker and Podman -3. Test with and without `AUTOFIX=true`: - - `super-linter.sh`: Verify `FIX_*` vars are filtered - correctly - - `renovate-deps.py`: Verify it regenerates and updates - the file - - Link linters: Verify they run normally and don't - output warnings -4. For Renovate scripts, ensure they handle missing deps - gracefully +- **v1** (stable): reusable bash task scripts consumed as + HTTP remote tasks. See [AGENTS-V1.md](AGENTS-V1.md). +- **v2** (in development, `feat/flint-v2` branch): a single + Rust binary. See [AGENTS-V2.md](AGENTS-V2.md). ## Linting -**Always run `mise run fix` before committing changes.** +**Always run `mise run lint:fix` before committing changes.** This ensures all files pass CI linting (Biome formatting, shellcheck, etc.). Review the auto-fixed files before committing — auto-fixes may produce unexpected results. @@ -115,62 +26,15 @@ workflow — both are optional. To install the Git hook: ```bash # Auto-fix and verify (recommended dev workflow) -mise run fix +mise run lint:fix # Verify only (same command used in CI) mise run lint # Install git pre-commit hook (one-time, opt-in) -mise run setup:pre-commit-hook +flint hook install ``` -## Native Mode Tips - -For faster native linting, consider switching -`super-linter.env` from a deny-list -(`VALIDATE_X=false` for each unwanted linter) to an -allow-list (only `VALIDATE_X=true` for linters you -want). Super-linter's logic — and native mode — treats -any explicit `VALIDATE_*=true` as "only run these". -This avoids noise from linters like `golangci-lint` -running on non-Go repos. - -After updating the super-linter version in `mise.toml`, -run `mise run setup:native-lint-tools` on the host to -install matching tool versions. Native mode fails if -enabled tools are missing. - -**Config files:** Native mode requires linter configs at -standard locations (project root), not in -`.github/linters/` (super-linter's convention). The -script errors if `.github/linters/` exists. All -supported linters auto-discover their config: -`textlint`→`.textlintrc`, -`shellcheck`→`.shellcheckrc`, -`markdownlint`→`.markdownlint.json`, -`ec` (editorconfig-checker)→`.ecrc`, -`actionlint`→`.github/actionlint.yml`, -`hadolint`→`.hadolint.yaml`, -`golangci-lint`→`.golangci.yml`, -`ruff`→`ruff.toml`/`pyproject.toml`, -`codespell`→`.codespellrc`/`pyproject.toml`, -`biome`→`biome.json`, -`prettier`→`.prettierrc`, -`shfmt`→`.editorconfig`. - -## Adding New Linters - -When adding new lint scripts, follow these patterns: - -1. **Add AUTOFIX support**: Check for `AUTOFIX` env var and - implement fix behavior if the underlying tool supports it -2. **Silent fallback**: If the tool doesn't support autofix, - silently ignore `AUTOFIX` (no warnings or errors) -3. **Consistent behavior**: Ensure the script works the same - whether `AUTOFIX` is set or not for check-only tools -4. **Document support**: Update README.md table to show - whether AUTOFIX is supported - ## Commit Messages This repository uses @@ -196,13 +60,3 @@ affect consumers (documentation, CI workflows, repository config). Misusing `fix:` for non-functional changes creates unnecessary releases. - -## Script Conventions - -- Shell scripts use `set -euo pipefail` for safety -- Python scripts check for `MISE_PROJECT_ROOT` and exit - with clear error if missing -- Use `# shellcheck disable=` with justification when - intentionally violating shellcheck rules -- Python scripts use `sys.exit(1)` on errors, print errors - to stderr diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7c52da5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1092 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "flint" +version = "0.20.0-alpha.1" +dependencies = [ + "anyhow", + "clap", + "crossterm", + "dunce", + "figment", + "globset", + "regex", + "semver", + "serde", + "serde_json", + "similar", + "tempfile", + "tokio", + "toml", + "toml_edit", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2b9f596 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "flint" +version = "0.20.0-alpha.1" +edition = "2024" +description = "mise-native lint orchestrator" +license = "Apache-2.0" +repository = "https://github.com/grafana/flint" + +[dependencies] +anyhow = "1" +crossterm = "0.28" +clap = { version = "4", features = ["derive", "env"] } +figment = { version = "0.10", features = ["toml", "env"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" +tokio = { version = "1", features = ["full"] } +semver = "1" +regex = "1" +globset = "0.4" +serde_json = "1" +similar = "2" +toml_edit = "0.22" +dunce = "1.0.5" + +[dev-dependencies] +tempfile = "3" +regex = "1" diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..3f6833b --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,37 @@ +# Migration Guide + +## Migrating from flint v1 (bash tasks) to flint v2 (binary) + +flint v2 replaces the HTTP remote tasks with a single `flint` binary that +discovers linters from your `mise.toml` and runs them against changed files. + +### 1. Add `flint` as a tool + +```toml +[tools] +"ubi:grafana/flint" = "0.20.0-alpha.1" +``` + +### 2. Run `flint init` + +After installing flint (`mise install`), run `flint init`. It automatically: + +- removes v1 HTTP task entries from `[tasks]` +- removes `RENOVATE_TRACKED_DEPS_EXCLUDE` from `[env]` and migrates the manager list to `flint.toml` (when a v1 renovate-deps task is present) +- replaces `npm:markdownlint-cli` with `npm:markdownlint-cli2` in `[tools]` +- adds the missing linters to `[tools]` based on your tracked files +- adds `[env] FLINT_CONFIG_DIR` and standard `lint*` / `setup:pre-commit-hook` tasks +- writes a `flint.toml` skeleton in your chosen config dir +- generates `.github/workflows/lint.yml` +- patches `renovate.json5` to add the flint preset + +Then run `mise install` to install the new tools and +`mise run setup:pre-commit-hook` to install the git hook. + +Finally, run `flint run --fix renovate-deps` to regenerate +`renovate-tracked-deps.json` with all the new tools included. + +### 3. Verify active linters + +Run `flint linters` to confirm flint detects all the tools declared in your +`mise.toml`. Any tool listed as `missing` is not declared and will be skipped. diff --git a/README-V1.md b/README-V1.md new file mode 100644 index 0000000..7f88929 --- /dev/null +++ b/README-V1.md @@ -0,0 +1,544 @@ +# flint v1 (legacy) + +> [!NOTE] +> **This is the legacy v1 documentation** (bash task scripts consumed as +> mise HTTP remote tasks). The current version is [flint v2](README.md) — +> a single Rust binary. + +A toolbox of reusable [mise](https://mise.jdx.dev/) lint task scripts. +Pick the ones you need — each task is independent and can be adopted +on its own. + +**Available tasks:** + +| Task | Tool | +| -------------------- | ------------------------------------------------------------- | +| `lint:super-linter` | [Super-Linter](https://github.com/super-linter/super-linter) | +| `lint:links` | [lychee](https://lychee.cli.rs/) | +| `lint:renovate-deps` | [Renovate](https://docs.renovatebot.com/) dependency tracking | + +## How it works + +Flint relies on two tools that each play a distinct role: + +### mise — the task runner + +[mise](https://mise.jdx.dev/) is a polyglot dev tool manager and task +runner. In the context of flint, mise serves two purposes: + +1. **Installing tools.** mise's `[tools]` section pins exact versions + of the linters each task needs (e.g., `lychee`, `node`, + `"npm:renovate"`). Running `mise install` gives every developer and + CI runner the same versions, so local runs are consistent with CI. + +2. **Running tasks.** mise downloads task scripts from this repository + via HTTP, wires them into your project as local commands + (`mise run lint`, `mise run fix`), and passes flags and environment + variables through to each script. You don't need to clone flint — + mise fetches the scripts directly from GitHub URLs pinned in your + `mise.toml`. + +### Renovate — the dependency updater + +[Renovate](https://docs.renovatebot.com/) is an automated dependency update bot. +Extending the flint [Renovate preset](#automatic-version-updates-with-renovate) +(`default.json`) is essential for any repository that uses flint — without it, +SHA-pinned flint URLs and `_VERSION` variables in `mise.toml` would never get +updated. The preset ships custom managers that detect these patterns and open +PRs to bump both flint itself and the tools it runs +(e.g., Super-Linter, lychee). + +Optionally, the [`lint:renovate-deps`](#lintrenovate-deps) task adds a second +layer: it runs Renovate locally to detect which dependencies Renovate is +tracking, compares this against a committed snapshot, and fails if they +diverge — catching cases where a dependency silently falls off Renovate's +radar. + +## Usage + +> [!WARNING] +> Always pin to a specific version, never use `main`. +> The main branch may contain breaking changes. +> See [CHANGELOG.md](CHANGELOG.md) for version history. + +Add whichever tasks you need as HTTP remote tasks in your `mise.toml`, +pinned to the commit SHA of a release tag with a version comment: + + + +```toml +# Pick the tasks you need from flint (https://github.com/grafana/flint) +[tasks."lint:super-linter"] +description = "Run Super-Linter on the repository" +file = "https://raw.githubusercontent.com/grafana/flint/8a39d96e17327c54974623b252eb5260d67ed68a/tasks/lint/super-linter.sh" # v0.9.1 +[tasks."lint:links"] +description = "Check for broken links in changed files + all local links" +file = "https://raw.githubusercontent.com/grafana/flint/8a39d96e17327c54974623b252eb5260d67ed68a/tasks/lint/links.sh" # v0.9.1 +[tasks."lint:renovate-deps"] +description = "Verify renovate-tracked-deps.json is up to date" +file = "https://raw.githubusercontent.com/grafana/flint/8a39d96e17327c54974623b252eb5260d67ed68a/tasks/lint/renovate-deps.py" # v0.9.1 +``` + + + +The SHA pin ensures the URL is immutable (tag-based URLs can change +if a tag is force-pushed), and the `# v0.3.0` comment tells Renovate +which version is currently pinned. + +Then wire up top-level `lint` and `fix` tasks that reference whichever tasks +you adopted (add any project-specific subtasks to the `depends` list): + +```toml +[tasks."lint:fast"] +description = "Run fast lints (no Renovate)" +depends = ["lint:super-linter", "lint:links"] + +[tasks.lint] +description = "Run all lints" +depends = ["lint:fast", "lint:renovate-deps"] + +[tasks.fix] +description = "Auto-fix lint issues and regenerate tracked deps" +run = "AUTOFIX=true mise run lint" + +[tasks.native-lint] +description = "Run lints natively (no container)" +run = "NATIVE=true mise run lint:fast" +``` + +Finally, extend the flint [Renovate preset](#automatic-version-updates-with-renovate) +in your `renovate.json5` to keep flint and its tools up to date: + +```json5 +{ + extends: ["github>grafana/flint"], +} +``` + +Without this, SHA-pinned flint URLs and tool versions (e.g., +`SUPER_LINTER_VERSION`) in `mise.toml` will never receive automated +updates. + +## Example + +See [grafana/docker-otel-lgtm][example-repo] for a real-world example +of a repository using flint. Its [CONTRIBUTING.md][example-contributing] +describes the developer workflow, and its [mise.toml][example-mise] +shows how the tasks are wired up. + +[example-repo]: https://github.com/grafana/docker-otel-lgtm +[example-contributing]: https://github.com/grafana/docker-otel-lgtm/blob/main/CONTRIBUTING.md +[example-mise]: https://github.com/grafana/docker-otel-lgtm/blob/main/mise.toml + +## Tasks + +### `lint:super-linter` + +Runs [Super-Linter](https://github.com/super-linter/super-linter) +via Docker or Podman. Auto-detects the container runtime (prefers +Podman, falls back to Docker) and handles SELinux bind-mount flags +on Fedora. + +**mise** fetches this script from the SHA-pinned URL in `mise.toml` +and runs it as `mise run lint:super-linter`. The +`SUPER_LINTER_VERSION` environment variable (set in `mise.toml`) +controls which Super-Linter image is pulled. **Renovate**, via the +flint preset, opens PRs to bump both the flint script URL and the +`SUPER_LINTER_VERSION` value when new versions are available. + +**Slim vs full image:** Super-Linter publishes a slim image +(`slim-v8.4.0`) that is ~2 GB smaller than the full image. The slim +image excludes Rust, .NET/C#, PowerShell, and ARM template linters. +Flint defaults to the slim image. To use the full image instead, set +`SUPER_LINTER_VERSION` to the non-prefixed tag (e.g. +`v8.4.0@sha256:...`) and update the Renovate `depName` comment +accordingly (drop the `versioning` override so Renovate uses standard +Docker versioning). + +**Flags:** + +| Flag | Description | +| ----------- | ------------------------------------------------------------ | +| `--autofix` | Enable autofix mode (enables `FIX_*` vars from the env file) | +| `--native` | Run linters natively instead of via container | +| `--full` | Lint all files instead of only changed files | + +`--autofix` and `--native` can also be set via the `AUTOFIX=true` +and `NATIVE=true` environment variables respectively. This is how +the `fix` and `native-lint` meta-tasks propagate them through the +`depends` chain. + +When autofix is not enabled, all `FIX_*` lines are filtered out of +the env file before running Super-Linter. + +**Native mode:** + +The `--native` flag runs a **subset** of linters directly on +the host for fast local feedback. It is not a full replacement +for the Super-Linter container — CI should always use the +container for comprehensive checks. + +Native mode reads the same `super-linter.env` file and follows +Super-Linter's default logic for determining which linters are +enabled: if any `VALIDATE_*=true` is set, only those linters run; +otherwise all linters run unless explicitly `VALIDATE_*=false`. +`FILTER_REGEX_EXCLUDE` is respected. `FIX_*` variables are honored +when `--autofix` is also set. + +Supported native linters (subset of super-linter): + +- `actionlint` +- `biome` +- `codespell` +- `editorconfig-checker` +- `golangci-lint` +- `hadolint` +- `markdownlint` +- `prettier` +- `ruff` +- `shellcheck` +- `shfmt` + +Tools must be installed separately (e.g., via +`mise run setup:native-lint-tools`). Missing tools and unsupported +`VALIDATE_*` flags are skipped with a warning. Linter configs must +be at standard project-root locations (not `.github/linters/`). + +**Environment variables:** + + + +| Variable | Default | Required | Description | +| ----------------------- | --------------------------------- | -------- | --------------------------------------------------------------------------------------------- | +| `SUPER_LINTER_VERSION` | — | yes | Super-Linter image tag (e.g. `slim-v8.4.0@sha256:...` for slim, `v8.4.0@sha256:...` for full) | +| `SUPER_LINTER_ENV_FILE` | `.github/config/super-linter.env` | no | Path to the Super-Linter env file | + + + +### `lint:links` + +Checks links with [lychee](https://lychee.cli.rs/). By default, it +runs two checks: **all links (local + remote) in modified files** and +**local file links in all files**. This keeps CI fast while catching +both broken remote links in changed content and broken internal links +across the whole repository. + +**mise** fetches this script and runs it as `mise run lint:links`. +Lychee is installed via mise's `[tools]` section — add +`lychee = ""` to your `mise.toml`. **Renovate**, via the +flint preset, opens PRs to bump the flint script URL when a new +version is available. + +**Flags:** + + + +| Flag | Description | +| ---------------------- | ------------------------------------------------------------------------------------ | +| `--full` | Check all links (local + remote) in all files (single run) | +| `--base ` | Base branch to compare against (default: `origin/$GITHUB_BASE_REF` or `origin/main`) | +| `--head ` | Head commit to compare against (default: `$GITHUB_HEAD_SHA` or `HEAD`) | +| `--lychee-args ` | Extra arguments to pass to lychee | +| `...` | Files to check (default: `.`; only used with `--full`) | + + + +When running in default mode, if a config change is detected +(matching `LYCHEE_CONFIG_CHANGE_PATTERN` or lychee-related changes +in `mise.toml`), the script falls back to `--full` behavior. +Changes to `mise.toml` are content-aware: only lychee-related +lines (e.g. version or task config) trigger a full check, not +unrelated tool version bumps. + +**GitHub URL remaps:** + +When running on a PR branch, the script automatically remaps GitHub +`/blob//` and `/tree//` URLs so that links +to the base branch resolve against the PR branch instead. This +ensures that links like `/blob/main/README.md` don't break when +the file was added or moved in the PR. + +For `/blob/` URLs, four ordered remap rules are applied +(lychee uses first-match-wins): + +1. **Line-number anchors** (`#L123`, `#L10-L20`): GitHub renders + these with JavaScript, so lychee can never verify the fragment. + The anchor is stripped and the file is checked on the PR branch. +2. **[Scroll to Text Fragment][stf] anchors** (`#:~:text=...`): + Browser-only feature, not present in static HTML. The anchor + is stripped and the file is checked on the PR branch. +3. **Other fragment URLs** (`#section`): Remapped to + `raw.githubusercontent.com` where lychee can verify the fragment + in the raw file content (workaround for + [lychee#1729](https://github.com/lycheeverse/lychee/issues/1729)). +4. **Non-fragment URLs**: Remapped from the base branch to the PR + branch (the original behavior). + +For `/tree/` URLs, rules 1 and 4 apply (no raw remap needed). + +**Global GitHub URL handling:** + +In addition to the PR-specific remaps above, the script handles +two patterns that affect ALL GitHub URLs (any repository): + +- **Line-number anchors** (`#L123`, `#L10-L20`): Stripped from + any GitHub `/blob/` URL. The file is still checked, but the + JS-rendered line-number fragment is skipped. This means + consuming repos don't need to exclude these in their + `lychee.toml`. +- **Scroll to Text Fragment anchors** (`#:~:text=...`): Stripped + from any GitHub `/blob/` URL. These are a browser-only feature + not present in static HTML. +- **Issue comment anchors** (`#issuecomment-*`): The fragment + is stripped so the issue/PR page is still checked, but the + JS-rendered comment anchor is skipped. + +Set `LYCHEE_SKIP_GITHUB_REMAPS=true` to disable all GitHub-specific +remaps as an escape hatch if they cause unexpected behavior. + +**Lychee config cleanup:** + +When adopting `lint:links`, you can remove the following entries +from your `lychee.toml` because flint handles them at runtime +via `--remap` arguments: + +- **GitHub blob/fragment remap for + [lychee#1729](https://github.com/lycheeverse/lychee/issues/1729)** + — flint remaps fragment URLs to `raw.githubusercontent.com` + for the current PR's head branch, and strips line-number + and Scroll to Text Fragment anchors globally. +- **`#issuecomment-*` excludes** — flint strips the fragment + via remap so the issue/PR page is still checked. +- **`#L\d+` / `#L\d+-L\d+` line-number excludes** — flint strips + the fragment via remap so the file is still checked. +- **`#:~:text=...` [Scroll to Text Fragment][stf] excludes** — + flint strips the fragment via remap so the file is still + checked. + +Note: flint uses `--remap` (not `--exclude`) for these because +lychee's CLI `--exclude` flags override config file excludes +rather than merging with them. + +**Environment variables:** + + + +| Variable | Default | Description | +| ------------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `LYCHEE_CONFIG` | `.github/config/lychee.toml` | Path to the lychee config file | +| `LYCHEE_CONFIG_CHANGE_PATTERN` | `^(\.github/config/lychee\.toml\|\.mise/tasks/lint/.*)$` | Files whose change triggers a full link check (`mise.toml` checked separately) | +| `LYCHEE_SKIP_GITHUB_REMAPS` | unset | Set to `true` to disable all GitHub URL remaps | + + + +**Examples:** + +```bash +mise run lint:links # All links in modified + local links in all files (default) +mise run lint:links --full # All links in all files +``` + +### `lint:renovate-deps` + +Verifies `.github/renovate-tracked-deps.json` is up to date by +running Renovate locally and parsing its debug logs. + +**mise** fetches this script and runs it as `mise run lint:renovate-deps`. +The Renovate CLI is installed via mise's `[tools]` section — add +`node = ""` and `"npm:renovate" = ""` to your +`mise.toml`. **Renovate** plays a dual role here: the flint preset +keeps the script URL up to date, while the script itself runs Renovate +locally in `--platform=local` mode to discover which dependencies +Renovate is tracking and compares them against a committed snapshot. + +**Flags:** + +| Flag | Description | +| ----------- | ------------------------------------------------------ | +| `--autofix` | Automatically regenerate and update the committed file | + +**Environment variables:** + + + +| Variable | Default | Description | +| ------------------------------- | ------- | ----------------------------------------------------------------------------------- | +| `RENOVATE_TRACKED_DEPS_EXCLUDE` | unset | Comma-separated Renovate managers to exclude (e.g. `github-actions,github-runners`) | + + + +#### Why this exists + +Renovate silently stops tracking a dependency when it can no longer +parse the version reference (typo in a comment annotation, +unsupported syntax, moved file, etc.). When that happens, the +dependency freezes in place with no PR and no dashboard entry — it +simply disappears from Renovate's radar. + +The Dependency Dashboard catches _known_ dependencies that are +pending or in error, but it cannot show you a dependency that +Renovate no longer sees at all. This linter closes that gap by +keeping a committed snapshot of every dependency Renovate tracks +and failing CI when the two diverge. + +#### How the linter works + +The `lint:renovate-deps` task runs Renovate locally in +`--platform=local` mode, parses its debug log for the +`packageFiles with updates` message, and generates a dependency +list (grouped by file and manager). It then diffs this against the +committed `.github/renovate-tracked-deps.json`: + +- If they match → linter passes +- If they differ → linter fails with a unified diff showing which + dependencies were added or removed +- With `--autofix` flag (or `AUTOFIX=true` env var) → automatically + regenerates and updates the committed file + +#### Typical workflow + +- **A dependency disappears** (e.g., someone removes a + `# renovate:` comment or changes a file that Renovate was + matching) → CI fails, showing the removed dependency in the diff. + The author can then decide whether the removal was intentional or + accidental. + +- **A new dependency is added** → CI fails because the committed + snapshot is stale. Run `mise run fix` (or + `AUTOFIX=true mise run lint:renovate-deps`) to regenerate and + update the file, then commit. + +- **Routine regeneration** → After any change to `renovate.json5`, + Dockerfiles, `go.mod`, `package.json`, or other files Renovate + scans, the linter will detect the change and require + regeneration. + +## How AUTOFIX and NATIVE Work + +`lint:super-linter` accepts `--autofix` and `--native` flags. +Both can also be set as environment variables (`AUTOFIX=true`, +`NATIVE=true`), which is how the `fix` and `native-lint` +meta-tasks propagate them — mise's `depends` cannot forward CLI +flags, but env vars flow through naturally. Tasks that don't +recognize these variables simply ignore them. + +**Check mode** (default): + +```bash +mise run lint # Check all linters, fail on issues +mise run lint:super-linter # Check code style, fail on issues +mise run lint:renovate-deps # Verify tracked deps, fail if out of date +``` + +**Fix mode:** + +```bash +mise run fix # Auto-fix all fixable issues +# Or run individual linters: +mise run lint:super-linter --autofix # Apply code fixes +mise run lint:renovate-deps --autofix # Regenerate tracked deps +``` + +Linters that don't support autofix (like lychee link checker) +silently ignore the `AUTOFIX` environment variable. + +**Native mode:** + +```bash +mise run native-lint # Fast lints, natively (no container) +# Or run directly: +NATIVE=true mise run lint:fast # Same effect +mise run lint:super-linter --native # Single task with CLI flag +``` + +Native mode is useful in environments where Docker/Podman is +unavailable (e.g., inside containers, CI hooks). `native-lint` +targets `lint:fast` (super-linter + links), skipping +`lint:renovate-deps` which requires the Renovate CLI. Tasks +that don't use a container (like `lint:links`) ignore the +`NATIVE` variable. + +## Pre-commit hook + +Flint provides a `pre-commit` task that runs native linters on +every commit — fast feedback without the container overhead. To +set it up: + +```bash +mise run setup:pre-commit-hook +``` + +This generates a `.git/hooks/pre-commit` that runs +`mise run pre-commit`, which uses native mode for fast checks +without requiring a container. + +**For consuming repos**, add these tasks to your `mise.toml`: + +```toml +[tasks.pre-commit] +description = "Pre-commit hook: native lint" +depends = ["setup:native-lint-tools"] +run = "NATIVE=true mise run lint:fast" + +[tasks."setup:pre-commit-hook"] +description = "Install git pre-commit hook" +run = "mise generate git-pre-commit --write --task=pre-commit" +``` + +Then run `mise run setup:pre-commit-hook` once per clone. + +## Automatic version updates with Renovate + +Flint provides a [Renovate shareable preset](https://docs.renovatebot.com/config-presets/) +with custom managers that automatically update: + +- **SHA-pinned flint versions** in `mise.toml` + (`raw.githubusercontent.com` URLs with commit SHA and version + comment) +- **`_VERSION` variables** in `mise.toml` (e.g., `SUPER_LINTER_VERSION`) + +Add this to your `renovate.json5`: + +```json5 +{ + extends: ["github>grafana/flint"], +} +``` + +## Per-repo configuration + +Each task expects certain config files that your repository must +provide. You only need the files for the tasks you adopt: + +- **`lint:super-linter`** — Super-Linter env file + (`.github/config/super-linter.env`) to select which validators + to enable and which `FIX_*` vars to set, plus any linter config + files (`.golangci.yaml`, `.markdownlint.yaml`, `.yaml-lint.yml`, + `.editorconfig`, etc.) +- **`lint:links`** — Lychee config + (`.github/config/lychee.toml`) for exclusions, timeouts, + remappings +- **`lint:renovate-deps`** — Renovate config + (`.github/renovate.json5`) and committed snapshot + (`.github/renovate-tracked-deps.json`) +- **Renovate preset** — Add `"github>grafana/flint"` to your + `renovate.json5` `extends` array to enable automatic updates of + flint URLs and tool versions + +## Versioning + +This project uses [Semantic Versioning](https://semver.org/). +Breaking changes will be documented in [CHANGELOG.md](CHANGELOG.md) +and will result in a major version bump. + +**Always pin to a specific commit SHA** in your `mise.toml` file +URLs with a version comment (e.g., `# v0.6.0`). Never reference +`main` directly as it may contain unreleased breaking changes. To +find the commit SHA for a release tag, run +`git rev-parse v0.6.0`. + +## Releasing + +See [RELEASING.md](RELEASING.md). + +[stf]: https://developer.mozilla.org/en-US/docs/Web/URI/Fragment/Text_fragments diff --git a/README.md b/README.md index 5f67a3b..259f1b6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ flint logo

-

flint

+

flint — fast lint

Lint @@ -13,524 +13,605 @@ -A toolbox of reusable [mise](https://mise.jdx.dev/) lint task scripts. -Pick the ones you need — each task is independent and can be adopted -on its own. +Linter runner built for speed and consistency: -**Available tasks:** +- **Fast** — native execution (no Docker), parallel, diff-aware (changed files only), opt-in (undeclared tools don't run), small binary cached by mise +- **Local == CI** — one binary, one config, identical behavior +- **AI-friendly** — fix silently, surface only what needs review +- **Cross-platform** — Linux, macOS, Windows +- **Autofix** — `--fix` fixes what's fixable; reports what still needs review -| Task | Tool | -| -------------------- | ------------------------------------------------------------- | -| `lint:super-linter` | [Super-Linter](https://github.com/super-linter/super-linter) | -| `lint:links` | [lychee](https://lychee.cli.rs/) | -| `lint:renovate-deps` | [Renovate](https://docs.renovatebot.com/) dependency tracking | +See [Why / Principles](#why) for background. -## How it works +> [!TIP] +> **Legacy v1** (bash task scripts): see [README-V1.md](README-V1.md). -Flint relies on two tools that each play a distinct role: +--- -### mise — the task runner +## Getting Started -[mise](https://mise.jdx.dev/) is a polyglot dev tool manager and task -runner. In the context of flint, mise serves two purposes: +### Installation -1. **Installing tools.** mise's `[tools]` section pins exact versions - of the linters each task needs (e.g., `lychee`, `node`, - `"npm:renovate"`). Running `mise install` gives every developer and - CI runner the same versions, so local runs are consistent with CI. +Add `flint` to your repo's `mise.toml` (once published): -2. **Running tasks.** mise downloads task scripts from this repository - via HTTP, wires them into your project as local commands - (`mise run lint`, `mise run fix`), and passes flags and environment - variables through to each script. You don't need to clone flint — - mise fetches the scripts directly from GitHub URLs pinned in your - `mise.toml`. - -### Renovate — the dependency updater - -[Renovate](https://docs.renovatebot.com/) is an automated dependency update bot. -Extending the flint [Renovate preset](#automatic-version-updates-with-renovate) -(`default.json`) is essential for any repository that uses flint — without it, -SHA-pinned flint URLs and `_VERSION` variables in `mise.toml` would never get -updated. The preset ships custom managers that detect these patterns and open -PRs to bump both flint itself and the tools it runs -(e.g., Super-Linter, lychee). +```toml +[tools] +flint = "0.x.y" +``` -Optionally, the [`lint:renovate-deps`](#lintrenovate-deps) task adds a second -layer: it runs Renovate locally to detect which dependencies Renovate is -tracking, compares this against a committed snapshot, and fails if they -diverge — catching cases where a dependency silently falls off Renovate's -radar. +Until the first published release, build from source: -## Usage +```bash +git clone https://github.com/grafana/flint +cd flint +cargo build --release +# Binary at target/release/flint +``` -⚠️ **Important**: Always pin to a specific version, never use `main`. -The main branch may contain breaking changes. -See [CHANGELOG.md](CHANGELOG.md) for version history. +### mise.toml setup -Add whichever tasks you need as HTTP remote tasks in your `mise.toml`, -pinned to the commit SHA of a release tag with a version comment: +Flint reads your `[tools]` section to discover which linters to run — declaring +a tool is the opt-in. No separate configuration needed to activate a check: if +`shellcheck` is in `[tools]`, flint runs shellcheck; if it isn't, that check is +skipped. `mise install` puts all declared tools on PATH; flint picks up whatever +is there. - +Add the linting tools your project needs alongside the `flint` binary itself: ```toml -# Pick the tasks you need from flint (https://github.com/grafana/flint) -[tasks."lint:super-linter"] -description = "Run Super-Linter on the repository" -file = "https://raw.githubusercontent.com/grafana/flint/df7b637ee421a491ca5c34b5be4122d8360f57c1/tasks/lint/super-linter.sh" # v0.9.2 -[tasks."lint:links"] -description = "Check for broken links in changed files + all local links" -file = "https://raw.githubusercontent.com/grafana/flint/df7b637ee421a491ca5c34b5be4122d8360f57c1/tasks/lint/links.sh" # v0.9.2 -[tasks."lint:renovate-deps"] -description = "Verify renovate-tracked-deps.json is up to date" -file = "https://raw.githubusercontent.com/grafana/flint/df7b637ee421a491ca5c34b5be4122d8360f57c1/tasks/lint/renovate-deps.py" # v0.9.2 +[tools] +flint = "0.x.y" + +# Add whichever linters apply to your repo: +shellcheck = "v0.11.0" +shfmt = "v3.12.0" +actionlint = "1.7.10" +"npm:markdownlint-cli2" = "0.47.0" +"npm:prettier" = "3.5.0" +rust = "1.87.0" # activates cargo-fmt + cargo-clippy +go = "1.24.0" # activates gofmt +lychee = "0.18.0" # activates links check +"npm:renovate" = "39.0.0" # activates renovate-deps check (slow) ``` - - -The SHA pin ensures the URL is immutable (tag-based URLs can change -if a tag is force-pushed), and the `# v0.3.0` comment tells Renovate -which version is currently pinned. - -Then wire up top-level `lint` and `fix` tasks that reference whichever tasks -you adopted (add any project-specific subtasks to the `depends` list): +Then wire up lint tasks: ```toml -[tasks."lint:fast"] -description = "Run fast lints (no Renovate)" -depends = ["lint:super-linter", "lint:links"] - [tasks.lint] description = "Run all lints" -depends = ["lint:fast", "lint:renovate-deps"] +run = "flint run" -[tasks.fix] -description = "Auto-fix lint issues and regenerate tracked deps" -run = "AUTOFIX=true mise run lint" +[tasks."lint:pre-commit"] +description = "Fast auto-fix lint pass — for pre-push hooks and agentic pipelines" +run = "flint run --fix --fast-only" -[tasks.native-lint] -description = "Run lints natively (no container)" -run = "NATIVE=true mise run lint:fast" +[tasks."lint:fix"] +description = "Auto-fix lint issues" +run = "flint run --fix" ``` -Finally, extend the flint [Renovate preset](#automatic-version-updates-with-renovate) -in your `renovate.json5` to keep flint and its tools up to date: +### CI setup -```json5 -{ - extends: ["github>grafana/flint"], -} +```yaml +- name: Checkout code + uses: actions/checkout@... + with: + fetch-depth: 0 # needed for merge-base detection + +- name: Setup mise + uses: jdx/mise-action@... + +- name: Lint + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: mise run lint ``` -Without this, SHA-pinned flint URLs and tool versions (e.g., -`SUPER_LINTER_VERSION`) in `mise.toml` will never receive automated -updates. - -## Example - -See [grafana/docker-otel-lgtm][example-repo] for a real-world example -of a repository using flint. Its [CONTRIBUTING.md][example-contributing] -describes the developer workflow, and its [mise.toml][example-mise] -shows how the tasks are wired up. - -[example-repo]: https://github.com/grafana/docker-otel-lgtm -[example-contributing]: https://github.com/grafana/docker-otel-lgtm/blob/main/CONTRIBUTING.md -[example-mise]: https://github.com/grafana/docker-otel-lgtm/blob/main/mise.toml - -## Tasks - -### `lint:super-linter` - -Runs [Super-Linter](https://github.com/super-linter/super-linter) -via Docker or Podman. Auto-detects the container runtime (prefers -Podman, falls back to Docker) and handles SELinux bind-mount flags -on Fedora. - -**mise** fetches this script from the SHA-pinned URL in `mise.toml` -and runs it as `mise run lint:super-linter`. The -`SUPER_LINTER_VERSION` environment variable (set in `mise.toml`) -controls which Super-Linter image is pulled. **Renovate**, via the -flint preset, opens PRs to bump both the flint script URL and the -`SUPER_LINTER_VERSION` value when new versions are available. - -**Slim vs full image:** Super-Linter publishes a slim image -(`slim-v8.4.0`) that is ~2 GB smaller than the full image. The slim -image excludes Rust, .NET/C#, PowerShell, and ARM template linters. -Flint defaults to the slim image. To use the full image instead, set -`SUPER_LINTER_VERSION` to the non-prefixed tag (e.g. -`v8.4.0@sha256:...`) and update the Renovate `depName` comment -accordingly (drop the `versioning` override so Renovate uses standard -Docker versioning). - -**Flags:** - -| Flag | Description | -| ----------- | ------------------------------------------------------------ | -| `--autofix` | Enable autofix mode (enables `FIX_*` vars from the env file) | -| `--native` | Run linters natively instead of via container | -| `--full` | Lint all files instead of only changed files | - -`--autofix` and `--native` can also be set via the `AUTOFIX=true` -and `NATIVE=true` environment variables respectively. This is how -the `fix` and `native-lint` meta-tasks propagate them through the -`depends` chain. - -When autofix is not enabled, all `FIX_*` lines are filtered out of -the env file before running Super-Linter. - -**Native mode:** - -The `--native` flag runs a **subset** of linters directly on -the host for fast local feedback. It is not a full replacement -for the Super-Linter container — CI should always use the -container for comprehensive checks. - -Native mode reads the same `super-linter.env` file and follows -Super-Linter's default logic for determining which linters are -enabled: if any `VALIDATE_*=true` is set, only those linters run; -otherwise all linters run unless explicitly `VALIDATE_*=false`. -`FILTER_REGEX_EXCLUDE` is respected. `FIX_*` variables are honored -when `--autofix` is also set. - -Supported native linters (subset of super-linter): - -- `actionlint` -- `biome` -- `codespell` -- `editorconfig-checker` -- `golangci-lint` -- `hadolint` -- `markdownlint` -- `prettier` -- `ruff` -- `shellcheck` -- `shfmt` - -Tools must be installed separately (e.g., via -`mise run setup:native-lint-tools`). Missing tools and unsupported -`VALIDATE_*` flags are skipped with a warning. Linter configs must -be at standard project-root locations (not `.github/linters/`). - -**Environment variables:** +`GITHUB_HEAD_SHA` tells flint which commit is the PR head when running in CI. +`fetch-depth: 0` is required for merge-base detection. - +--- -| Variable | Default | Required | Description | -| ----------------------- | --------------------------------- | -------- | --------------------------------------------------------------------------------------------- | -| `SUPER_LINTER_VERSION` | — | yes | Super-Linter image tag (e.g. `slim-v8.4.0@sha256:...` for slim, `v8.4.0@sha256:...` for full) | -| `SUPER_LINTER_ENV_FILE` | `.github/config/super-linter.env` | no | Path to the Super-Linter env file | +## Reference - +### CLI -### `lint:links` +```text +flint run [OPTIONS] [LINTERS...] +flint linters +flint version +``` -Checks links with [lychee](https://lychee.cli.rs/). By default, it -runs two checks: **all links (local + remote) in modified files** and -**local file links in all files**. This keeps CI fast while catching -both broken remote links in changed content and broken internal links -across the whole repository. +Commands and flags follow [golangci-lint](https://golangci-lint.run/) conventions — teams already using it don't need to re-learn the interface. -**mise** fetches this script and runs it as `mise run lint:links`. -Lychee is installed via mise's `[tools]` section — add -`lychee = ""` to your `mise.toml`. **Renovate**, via the -flint preset, opens PRs to bump the flint script URL when a new -version is available. +`flint run` flags: -**Flags:** +| Flag | Description | +| -------------------- | ---------------------------------------------------------------------------------------------- | +| `--fix` | Fix what's fixable, report what still needs review; exit 1 if anything changed or needs review | +| `--full` | Lint all files instead of only changed files | +| `--fast-only` | Skip slow checks (e.g. `renovate-deps`). Overridden by explicit linter names. | +| `--short` | Compact summary output, no per-check noise | +| `--verbose` | Show all linter output, not just failures | +| `--new-from-rev REV` | Diff base (default: merge base with base branch) | +| `--to-ref REF` | Diff head (default: HEAD) | - +Every flag has an env var equivalent: `FLINT_FIX`, `FLINT_FULL`, `FLINT_FAST_ONLY`, +`FLINT_VERBOSE`, `FLINT_SHORT`, `FLINT_NEW_FROM_REV`, `FLINT_TO_REF`. -| Flag | Description | -| ---------------------- | ------------------------------------------------------------------------------------ | -| `--full` | Check all links (local + remote) in all files (single run) | -| `--base ` | Base branch to compare against (default: `origin/$GITHUB_BASE_REF` or `origin/main`) | -| `--head ` | Head commit to compare against (default: `$GITHUB_HEAD_SHA` or `HEAD`) | -| `--lychee-args ` | Extra arguments to pass to lychee | -| `...` | Files to check (default: `.`; only used with `--full`) | +#### Intended use by context - +| Context | Command | Why | +| ---------------------------- | -------------------------------------- | ----------------------------------------------------------------- | +| Interactive development | `flint run` or `flint run --fast-only` | Full output so you can read the details | +| Human wanting a summary | `flint run --short` | Compact output, no per-check noise | +| Pre-push hook (CC / agentic) | `flint run --fix --fast-only` | Fixes what it can silently, surfaces only what needs human review | +| CI | `flint run` | Full output for humans reading CI logs | -When running in default mode, if a config change is detected -(matching `LYCHEE_CONFIG_CHANGE_PATTERN` or lychee-related changes -in `mise.toml`), the script falls back to `--full` behavior. -Changes to `mise.toml` are content-aware: only lychee-related -lines (e.g. version or task config) trigger a full check, not -unrelated tool version bumps. - -**GitHub URL remaps:** - -When running on a PR branch, the script automatically remaps GitHub -`/blob//` and `/tree//` URLs so that links -to the base branch resolve against the PR branch instead. This -ensures that links like `/blob/main/README.md` don't break when -the file was added or moved in the PR. - -For `/blob/` URLs, four ordered remap rules are applied -(lychee uses first-match-wins): - -1. **Line-number anchors** (`#L123`, `#L10-L20`): GitHub renders - these with JavaScript, so lychee can never verify the fragment. - The anchor is stripped and the file is checked on the PR branch. -2. **[Scroll to Text Fragment][stf] anchors** (`#:~:text=...`): - Browser-only feature, not present in static HTML. The anchor - is stripped and the file is checked on the PR branch. -3. **Other fragment URLs** (`#section`): Remapped to - `raw.githubusercontent.com` where lychee can verify the fragment - in the raw file content (workaround for - [lychee#1729](https://github.com/lycheeverse/lychee/issues/1729)). -4. **Non-fragment URLs**: Remapped from the base branch to the PR - branch (the original behavior). - -For `/tree/` URLs, rules 1 and 4 apply (no raw remap needed). - -**Global GitHub URL handling:** - -In addition to the PR-specific remaps above, the script handles -two patterns that affect ALL GitHub URLs (any repository): - -- **Line-number anchors** (`#L123`, `#L10-L20`): Stripped from - any GitHub `/blob/` URL. The file is still checked, but the - JS-rendered line-number fragment is skipped. This means - consuming repos don't need to exclude these in their - `lychee.toml`. -- **Scroll to Text Fragment anchors** (`#:~:text=...`): Stripped - from any GitHub `/blob/` URL. These are a browser-only feature - not present in static HTML. -- **Issue comment anchors** (`#issuecomment-*`): The fragment - is stripped so the issue/PR page is still checked, but the - JS-rendered comment anchor is skipped. - -Set `LYCHEE_SKIP_GITHUB_REMAPS=true` to disable all GitHub-specific -remaps as an escape hatch if they cause unexpected behavior. - -**Lychee config cleanup:** - -When adopting `lint:links`, you can remove the following entries -from your `lychee.toml` because flint handles them at runtime -via `--remap` arguments: - -- **GitHub blob/fragment remap for - [lychee#1729](https://github.com/lycheeverse/lychee/issues/1729)** - — flint remaps fragment URLs to `raw.githubusercontent.com` - for the current PR's head branch, and strips line-number - and Scroll to Text Fragment anchors globally. -- **`#issuecomment-*` excludes** — flint strips the fragment - via remap so the issue/PR page is still checked. -- **`#L\d+` / `#L\d+-L\d+` line-number excludes** — flint strips - the fragment via remap so the file is still checked. -- **`#:~:text=...` [Scroll to Text Fragment][stf] excludes** — - flint strips the fragment via remap so the file is still - checked. - -Note: flint uses `--remap` (not `--exclude`) for these because -lychee's CLI `--exclude` flags override config file excludes -rather than merging with them. - -**Environment variables:** +**`--short` output** — failed checks partitioned by fixability, fixable ones +expressed as the exact command to run: - +```text +flint: 2 checks failed — flint run --fix prettier cargo-fmt | review: shellcheck +``` -| Variable | Default | Description | -| ------------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `LYCHEE_CONFIG` | `.github/config/lychee.toml` | Path to the lychee config file | -| `LYCHEE_CONFIG_CHANGE_PATTERN` | `^(\.github/config/lychee\.toml\|\.mise/tasks/lint/.*)$` | Files whose change triggers a full link check (`mise.toml` checked separately) | -| `LYCHEE_SKIP_GITHUB_REMAPS` | unset | Set to `true` to disable all GitHub URL remaps | +**`--fix` output** — fixes what's fixable, then prints the full output of +any checks that still need review, followed by a summary line. Exits 1 if +anything was fixed (so the caller commits the fixes before pushing) or if +anything still needs review. Exits 0 only if everything was already clean: - +```text +[shellcheck] -**Examples:** +In bad.sh line 2: +echo $1 + ^-- SC2086 (info): Double quote to prevent globbing and word splitting. +... +flint: fixed: cargo-fmt — commit before pushing | review: shellcheck +``` + +Pass one or more linter names to run only those: ```bash -mise run lint:links # All links in modified + local links in all files (default) -mise run lint:links --full # All links in all files +flint run shellcheck shfmt # run only shellcheck and shfmt +flint run --fix prettier # fix only prettier ``` -### `lint:renovate-deps` +`flint linters` shows every check with its status: -Verifies `.github/renovate-tracked-deps.json` is up to date by -running Renovate locally and parsing its debug logs. +```text +NAME BINARY STATUS SPEED PATTERNS +------------------------------------------------------------------- +shellcheck shellcheck installed fast *.sh *.bash *.bats +cargo-fmt cargo-fmt missing fast *.rs +renovate-deps renovate installed slow +... +``` -**mise** fetches this script and runs it as `mise run lint:renovate-deps`. -The Renovate CLI is installed via mise's `[tools]` section — add -`node = ""` and `"npm:renovate" = ""` to your -`mise.toml`. **Renovate** plays a dual role here: the flint preset -keeps the script URL up to date, while the script itself runs Renovate -locally in `--platform=local` mode to discover which dependencies -Renovate is tracking and compares them against a committed snapshot. +### Config (`flint.toml`) -**Flags:** +Optional. Place in the repo root (or in `FLINT_CONFIG_DIR` — see below). All settings have defaults. -| Flag | Description | -| ----------- | ------------------------------------------------------ | -| `--autofix` | Automatically regenerate and update the committed file | +```toml +[settings] +base_branch = "main" # branch to diff against +exclude = ["CHANGELOG.md", "vendor/**"] # glob patterns — exclude matching files -**Environment variables:** +[checks.links] +config = ".github/config/lychee.toml" # lychee config path +check_all_local = true # second pass: local links in all files - +[checks.renovate-deps] +exclude_managers = ["github-actions", "cargo"] # skip these Renovate managers +``` -| Variable | Default | Description | -| ------------------------------- | ------- | ----------------------------------------------------------------------------------- | -| `RENOVATE_TRACKED_DEPS_EXCLUDE` | unset | Comma-separated Renovate managers to exclude (e.g. `github-actions,github-runners`) | +### `FLINT_CONFIG_DIR` - +Set this env var to consolidate config files in one directory (e.g. `.github/config`): -#### Why this exists +```toml +# mise.toml +[env] +FLINT_CONFIG_DIR = ".github/config" +``` -Renovate silently stops tracking a dependency when it can no longer -parse the version reference (typo in a comment annotation, -unsupported syntax, moved file, etc.). When that happens, the -dependency freezes in place with no PR and no dashboard entry — it -simply disappears from Renovate's radar. +When set, `flint.toml` is loaded from that directory, and each linter that supports +an explicit config file path via a CLI flag will have it injected automatically when +the corresponding file exists there (see the "Config file" column in the table below). +Files that are absent are silently skipped — existing project-root configs remain in +effect. -The Dependency Dashboard catches _known_ dependencies that are -pending or in error, but it cannot show you a dependency that -Renovate no longer sees at all. This linter closes that gap by -keeping a committed snapshot of every dependency Renovate tracks -and failing CI when the two diverge. +**Note:** `editorconfig-checker`'s config file (`.editorconfig-checker.json`) controls its own settings, +not `.editorconfig` itself — editorconfig discovery always walks up from the file +being linted and cannot be redirected via a flag. -#### How the linter works +### Built-in linter registry -The `lint:renovate-deps` task runs Renovate locally in -`--platform=local` mode, parses its debug log for the -`packageFiles with updates` message, and generates a dependency -list (grouped by file and manager). It then diffs this against the -committed `.github/renovate-tracked-deps.json`: + + + + +| Name | Description | Fix | +| ---------------------- | ------------------------------------------------------------------- | --- | +| `actionlint` | Lint GitHub Actions workflow files | — | +| `biome` | Lint JS/TS/JSON files | yes | +| `biome-format` | Format JS/TS/JSON files | yes | +| `cargo-clippy` | Lint Rust code; runs on all .rs files, not just changed | yes | +| `cargo-fmt` | Format Rust code; runs on all .rs files, not just changed | yes | +| `codespell` | Check for common spelling mistakes | yes | +| `dotnet-format` | Format C# code | yes | +| `editorconfig-checker` | Check files comply with EditorConfig settings | — | +| `gofmt` | Format Go code | yes | +| `golangci-lint` | Lint Go code; uses --new-from-rev to scope analysis to changed code | — | +| `google-java-format` | Format Java code | yes | +| `hadolint` | Lint Dockerfiles | — | +| `ktlint` | Lint and format Kotlin code | yes | +| `license-header` | Check source files have the required license header | — | +| `lychee` | Check for broken links | — | +| `markdownlint-cli2` | Lint Markdown files for style and consistency | yes | +| `prettier` | Format Markdown and YAML files | yes | +| `renovate-deps` | Verify Renovate dependency snapshot is up to date | yes | +| `ruff` | Lint Python code | yes | +| `ruff-format` | Format Python code | yes | +| `shellcheck` | Lint shell scripts for common mistakes | — | +| `shfmt` | Format shell scripts | yes | +| `xmllint` | Validate XML files are well-formed | — | + +#### `actionlint` + +| | | +| ----------- | -------------------------------------------------- | +| Description | Lint GitHub Actions workflow files | +| Fix | no | +| Binary | `actionlint` | +| Scope | [file](#scopes) | +| Patterns | `.github/workflows/*.yml .github/workflows/*.yaml` | +| Config | `actionlint.yml` | + +#### `biome` + +| | | +| ----------- | -------------------------------------- | +| Description | Lint JS/TS/JSON files | +| Fix | yes | +| Binary | `biome` | +| Scope | [file](#scopes) | +| Patterns | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | + +#### `biome-format` + +| | | +| ----------- | -------------------------------------- | +| Description | Format JS/TS/JSON files | +| Fix | yes | +| Binary | `biome` | +| Scope | [file](#scopes) | +| Patterns | `*.json *.jsonc *.js *.ts *.jsx *.tsx` | + +#### `cargo-clippy` + +| | | +| ----------- | ------------------------------------------------------- | +| Description | Lint Rust code; runs on all .rs files, not just changed | +| Fix | yes | +| Binary | `cargo-clippy` | +| Scope | [project](#scopes) | +| Patterns | `*.rs` | + +#### `cargo-fmt` + +| | | +| ----------- | --------------------------------------------------------- | +| Description | Format Rust code; runs on all .rs files, not just changed | +| Fix | yes | +| Binary | `rustfmt` | +| Scope | [project](#scopes) | +| Patterns | `*.rs` | + +#### `codespell` + +| | | +| ----------- | ---------------------------------- | +| Description | Check for common spelling mistakes | +| Fix | yes | +| Binary | `codespell` | +| Scope | [files](#scopes) | +| Patterns | `*` | +| Config | `.codespellrc` | + +#### `dotnet-format` + +| | | +| ----------- | ---------------- | +| Description | Format C# code | +| Fix | yes | +| Binary | `dotnet` | +| Scope | [files](#scopes) | +| Patterns | `*.cs` | + +#### `editorconfig-checker` + +| | | +| ----------- | --------------------------------------------- | +| Description | Check files comply with EditorConfig settings | +| Fix | no | +| Binary | `ec` | +| Scope | [files](#scopes) | +| Patterns | `*` | +| Config | `.editorconfig-checker.json` | + +#### `gofmt` + +| | | +| ----------- | --------------- | +| Description | Format Go code | +| Fix | yes | +| Binary | `gofmt` | +| Scope | [file](#scopes) | +| Patterns | `*.go` | + +#### `golangci-lint` + +| | | +| ----------- | ------------------------------------------------------------------- | +| Description | Lint Go code; uses --new-from-rev to scope analysis to changed code | +| Fix | no | +| Binary | `golangci-lint` | +| Scope | [project](#scopes) | +| Patterns | `*.go` | +| Config | `.golangci.yml` | + +#### `google-java-format` + +| | | +| ----------- | -------------------- | +| Description | Format Java code | +| Fix | yes | +| Binary | `google-java-format` | +| Scope | [files](#scopes) | +| Patterns | `*.java` | + +#### `hadolint` + +| | | +| ----------- | -------------------------------------- | +| Description | Lint Dockerfiles | +| Fix | no | +| Binary | `hadolint` | +| Scope | [file](#scopes) | +| Patterns | `Dockerfile Dockerfile.* *.dockerfile` | +| Config | `.hadolint.yaml` | + +#### `ktlint` + +| | | +| ----------- | --------------------------- | +| Description | Lint and format Kotlin code | +| Fix | yes | +| Binary | `ktlint` | +| Scope | [files](#scopes) | +| Patterns | `*.kt *.kts` | + +#### `license-header` + +| | | +| ----------- | --------------------------------------------------- | +| Description | Check source files have the required license header | +| Fix | no | +| Binary | (built-in) | +| Scope | [special](#scopes) | + +#### `lychee` + +| | | +| ----------- | ---------------------------------- | +| Description | Check for broken links | +| Fix | no | +| Binary | `lychee` | +| Scope | [special](#scopes) | +| Config | via `[checks.links]` in flint.toml | + +Orchestrates [lychee](https://lychee.cli.rs/) for link checking. Requires `lychee` in `[tools]`. + +Default behavior: checks all links in changed files. When `check_all_local = true` in `flint.toml`, adds a second pass over local links in all files — useful when broken internal links from unchanged files also matter. + +Configure via `flint.toml`: -- If they match → linter passes -- If they differ → linter fails with a unified diff showing which - dependencies were added or removed -- With `--autofix` flag (or `AUTOFIX=true` env var) → automatically - regenerates and updates the committed file +```toml +[checks.links] +config = ".github/config/lychee.toml" +check_all_local = true +``` -#### Typical workflow +#### `markdownlint-cli2` -- **A dependency disappears** (e.g., someone removes a - `# renovate:` comment or changes a file that Renovate was - matching) → CI fails, showing the removed dependency in the diff. - The author can then decide whether the removal was intentional or - accidental. +| | | +| ----------- | --------------------------------------------- | +| Description | Lint Markdown files for style and consistency | +| Fix | yes | +| Binary | `markdownlint-cli2` | +| Scope | [file](#scopes) | +| Patterns | `*.md` | +| Config | `.markdownlint.jsonc` | -- **A new dependency is added** → CI fails because the committed - snapshot is stale. Run `mise run fix` (or - `AUTOFIX=true mise run lint:renovate-deps`) to regenerate and - update the file, then commit. +#### `prettier` -- **Routine regeneration** → After any change to `renovate.json5`, - Dockerfiles, `go.mod`, `package.json`, or other files Renovate - scans, the linter will detect the change and require - regeneration. +| | | +| ----------- | ------------------------------ | +| Description | Format Markdown and YAML files | +| Fix | yes | +| Binary | `prettier` | +| Scope | [files](#scopes) | +| Patterns | `*.md *.yml *.yaml` | +| Config | `.prettierrc` | -## How AUTOFIX and NATIVE Work +#### `renovate-deps` -`lint:super-linter` accepts `--autofix` and `--native` flags. -Both can also be set as environment variables (`AUTOFIX=true`, -`NATIVE=true`), which is how the `fix` and `native-lint` -meta-tasks propagate them — mise's `depends` cannot forward CLI -flags, but env vars flow through naturally. Tasks that don't -recognize these variables simply ignore them. +| | | +| ----------- | -------------------------------------------------------------------------------------------------------------------------- | +| Description | Verify Renovate dependency snapshot is up to date | +| Fix | yes | +| Binary | `renovate` | +| Scope | [special](#scopes) | +| Patterns | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | -**Check mode** (default): +Verifies `.github/renovate-tracked-deps.json` is up to date by running Renovate locally and comparing its output against the committed snapshot. Requires `renovate` in `[tools]`. -```bash -mise run lint # Check all linters, fail on issues -mise run lint:super-linter # Check code style, fail on issues -mise run lint:renovate-deps # Verify tracked deps, fail if out of date -``` +With `--fix`, automatically regenerates and commits the snapshot. -**Fix mode:** +Configure via `flint.toml`: -```bash -mise run fix # Auto-fix all fixable issues -# Or run individual linters: -mise run lint:super-linter --autofix # Apply code fixes -mise run lint:renovate-deps --autofix # Regenerate tracked deps +```toml +[checks.renovate-deps] +exclude_managers = ["github-actions", "github-runners"] ``` -Linters that don't support autofix (like lychee link checker) -silently ignore the `AUTOFIX` environment variable. +#### `ruff` + +| | | +| ----------- | ---------------- | +| Description | Lint Python code | +| Fix | yes | +| Binary | `ruff` | +| Scope | [file](#scopes) | +| Patterns | `*.py` | +| Config | `ruff.toml` | + +#### `ruff-format` + +| | | +| ----------- | ------------------ | +| Description | Format Python code | +| Fix | yes | +| Binary | `ruff` | +| Scope | [file](#scopes) | +| Patterns | `*.py` | +| Config | `ruff.toml` | + +#### `shellcheck` + +| | | +| ----------- | -------------------------------------- | +| Description | Lint shell scripts for common mistakes | +| Fix | no | +| Binary | `shellcheck` | +| Scope | [file](#scopes) | +| Patterns | `*.sh *.bash *.bats` | +| Config | `.shellcheckrc` | + +#### `shfmt` + +| | | +| ----------- | -------------------- | +| Description | Format shell scripts | +| Fix | yes | +| Binary | `shfmt` | +| Scope | [file](#scopes) | +| Patterns | `*.sh *.bash` | + +#### `xmllint` + +| | | +| ----------- | ---------------------------------- | +| Description | Validate XML files are well-formed | +| Fix | no | +| Binary | `xmllint` | +| Scope | [files](#scopes) | +| Patterns | `*.xml` | + + + -**Native mode:** +**Note:** Biome's config flag (`--config-path`) takes a directory, not a file path — +config injection for `biome` and `biome-format` is not yet implemented. -```bash -mise run native-lint # Fast lints, natively (no container) -# Or run directly: -NATIVE=true mise run lint:fast # Same effect -mise run lint:super-linter --native # Single task with CLI flag -``` +#### Scopes -Native mode is useful in environments where Docker/Podman is -unavailable (e.g., inside containers, CI hooks). `native-lint` -targets `lint:fast` (super-linter + links), skipping -`lint:renovate-deps` which requires the Renovate CLI. Tasks -that don't use a container (like `lint:links`) ignore the -`NATIVE` variable. +- `file` — invoked once per matched file +- `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 + `--new-from-rev` to scope analysis to changed code even within the project run. -## Pre-commit hook +**Slow checks** (Slow = yes) are skipped by `--fast-only`. Use `--fast-only` for +local/pre-push feedback and the full set in CI. -Flint provides a `pre-commit` task that runs native linters on -every commit — fast feedback without the container overhead. To -set it up: +**`editorconfig-checker` deference**: `editorconfig-checker` runs on all files, but +automatically skips file types owned by an active line-length-enforcing +formatter. When `cargo-fmt`, `ruff-format`, `biome-format`, or `prettier` +are active, their file types are excluded from `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. -```bash -mise run setup:pre-commit-hook -``` +## Why -This generates a `.git/hooks/pre-commit` that runs -`mise run pre-commit`, which uses native mode for fast checks -without requiring a container. +The bash task scripts (v1) have two problems: -**For consuming repos**, add these tasks to your `mise.toml`: +**Local ≠ CI**: `--native` runs a subset of linters; CI runs full super-linter +in Docker. Different tools, different behavior. Passing locally does not mean +passing in CI. -```toml -[tasks.pre-commit] -description = "Pre-commit hook: native lint" -depends = ["setup:native-lint-tools"] -run = "NATIVE=true mise run lint:fast" - -[tasks."setup:pre-commit-hook"] -description = "Install git pre-commit hook" -run = "mise generate git-pre-commit --write --task=pre-commit" -``` +**Bash has limits**: the registry pattern was already at the edge of what bash +does cleanly. Adding built-in checks (links, renovate) would make it worse. -Then run `mise run setup:pre-commit-hook` once per clone. +### Why not pre-commit? -## Automatic version updates with Renovate +pre-commit adds a parallel tool management system on top of mise. Consuming repos +already declare their tools in `mise.toml` — pre-commit would require maintaining +a second inventory of the same tools in `.pre-commit-config.yaml`, with its own +versioning and install lifecycle. That's friction without benefit for repos that +are already mise-first. -Flint provides a [Renovate shareable preset](https://docs.renovatebot.com/config-presets/) -with custom managers that automatically update: +### Why not Spotless (or other Maven formatter plugins)? -- **SHA-pinned flint versions** in `mise.toml` - (`raw.githubusercontent.com` URLs with commit SHA and version - comment) -- **`_VERSION` variables** in `mise.toml` (e.g., `SUPER_LINTER_VERSION`) +Spotless runs `google-java-format` as a Maven build phase, which means format +failures block compilation and test runs — that's the wrong place for a style +check. flint's `google-java-format` check runs as a separate lint step, only on +changed files, and is fast. -Add this to your `renovate.json5`: +To migrate: remove `spotless-maven-plugin` from your `pom.xml` (and any +`spotless.skip` properties), add `"github:google/google-java-format"` to +`[tools]` in `mise.toml`, and run `flint run --fix` once to confirm the repo is +clean. -```json5 -{ - extends: ["github>grafana/flint"], -} -``` +### Why not MegaLinter / super-linter? + +Container-based linters (super-linter, MegaLinter) ship their own tool versions, +independent of what the repo pins in `mise.toml`. This breaks the "declare once, +use everywhere" promise of mise. Container startup also adds latency to every run. + +## Principles + +1. **Fast** — the primary goal; everything else serves it: + - Native execution only (no Docker); linters run in parallel (Rust binary, short startup) + - Small binary, cached by mise — fast install, near-zero overhead between runs + - Diff-aware: only changed files are linted by default; `--full` to check everything + - Opt-in via `mise.toml`: undeclared tools are skipped entirely + - Slow checks (e.g. `renovate-deps`) tagged and skippable with `--fast-only` + +2. **Local same as CI** — one binary, one config, identical behavior. + No "native mode subset" distinction. If it passes locally, it passes in CI. -## Per-repo configuration - -Each task expects certain config files that your repository must -provide. You only need the files for the tasks you adopt: - -- **`lint:super-linter`** — Super-Linter env file - (`.github/config/super-linter.env`) to select which validators - to enable and which `FIX_*` vars to set, plus any linter config - files (`.golangci.yaml`, `.markdownlint.yaml`, `.yaml-lint.yml`, - `.editorconfig`, etc.) -- **`lint:links`** — Lychee config - (`.github/config/lychee.toml`) for exclusions, timeouts, - remappings -- **`lint:renovate-deps`** — Renovate config - (`.github/renovate.json5`) and committed snapshot - (`.github/renovate-tracked-deps.json`) -- **Renovate preset** — Add `"github>grafana/flint"` to your - `renovate.json5` `extends` array to enable automatic updates of - flint URLs and tool versions +3. **AI-friendly** — `--fix` fixes what's fixable silently, prints output + only for issues needing review, and exits with a structured summary: + + ```text + [shellcheck] + ... + flint: fixed: cargo-fmt — commit before pushing | review: shellcheck + ``` + + Only unfixable issues surface for review — no reasoning step required. + +4. **Cross-platform** — runs on Linux, macOS, and Windows. The built-in + registry accounts for platform differences (e.g. binary names, path quoting). + +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`). ## Versioning @@ -538,14 +619,6 @@ This project uses [Semantic Versioning](https://semver.org/). Breaking changes will be documented in [CHANGELOG.md](CHANGELOG.md) and will result in a major version bump. -**Always pin to a specific commit SHA** in your `mise.toml` file -URLs with a version comment (e.g., `# v0.6.0`). Never reference -`main` directly as it may contain unreleased breaking changes. To -find the commit SHA for a release tag, run -`git rev-parse v0.6.0`. - ## Releasing See [RELEASING.md](RELEASING.md). - -[stf]: https://developer.mozilla.org/en-US/docs/Web/URI/Fragment/Text_fragments diff --git a/mise.toml b/mise.toml index e4096e9..8742528 100644 --- a/mise.toml +++ b/mise.toml @@ -1,55 +1,50 @@ +[env] +FLINT_CONFIG_DIR = ".github/config" + [tools] lychee = "0.22.0" node = "24.14.1" -"npm:renovate" = "43.104.1" - -[env] -RENOVATE_TRACKED_DEPS_EXCLUDE="github-actions,github-runners" -# renovate: datasource=docker depName=ghcr.io/super-linter/super-linter -SUPER_LINTER_VERSION="slim-v8.5.0@sha256:857dcc3f0bf5dd065fdeed1ace63394bb2004238a5ef02910ea23d9bcd8fd2b8" - -# Dogfood our own lint tasks - use local file paths -[tasks."lint:super-linter"] -description = "Run Super-Linter on the repository" -file = "tasks/lint/super-linter.sh" - -[tasks."setup:native-lint-tools"] -description = "Install native lint tools matching the pinned super-linter version" -file = "tasks/setup/native-lint-tools.sh" +"npm:renovate" = "43.92.1" +"github:koalaman/shellcheck" = "v0.11.0" +"github:mvdan/sh" = "v3.12.0" +actionlint = "1.7.10" +editorconfig-checker = "v3.6.1" +"npm:markdownlint-cli2" = "0.17.2" +"npm:prettier" = "3.8.1" +"npm:@biomejs/biome" = "2.3.14" +"pipx:ruff" = "0.15.0" +"pipx:codespell" = "2.4.1" +rust = { version = "1.94.1", components = "clippy,rustfmt" } + +# Tools not used to lint this repo, but required for e2e tests +go = "1.26.2" +hadolint = "2.14.0" +"github:pinterest/ktlint" = "1.8.0" +dotnet = "10.0.201" +"cargo:xmloxide" = "0.4.1" +golangci-lint = "2.11.4" +"github:google/google-java-format" = "1.35.0" [tasks."setup:update-super-linter-versions"] description = "Generate super-linter version mapping from the super-linter repo" file = "tasks/setup/update-super-linter-versions.sh" -[tasks."lint:links"] -description = "Check for broken links in changed files + all local links" -file = "tasks/lint/links.sh" - -[tasks."lint:renovate-deps"] -description = "Verify renovate-tracked-deps.json is up to date" -file = "tasks/lint/renovate-deps.py" - -[tasks."lint:fast"] -description = "Run fast lints (no Renovate)" -depends = ["lint:super-linter", "lint:links"] - -[tasks."lint"] +[tasks.lint] description = "Run all lints" -depends = ["lint:fast", "lint:renovate-deps"] +run = "cargo run -q -- run" -[tasks.fix] -description = "Auto-fix lint issues and regenerate tracked deps" -run = "AUTOFIX=true mise run lint" +[tasks."lint:fix"] +description = "Auto-fix lint issues" +run = "cargo run -q -- run --fix" -[tasks.native-lint] -description = "Run lints natively (no container)" -run = "NATIVE=true mise run lint:fast" +[tasks."lint:pre-commit"] +description = "Fast auto-fix lint pass (skips slow checks like renovate) — intended for pre-commit/pre-push hooks" +run = "cargo run -q -- run --fix --fast-only" -[tasks.pre-commit] -description = "Pre-commit hook: native lint" -depends = ["setup:native-lint-tools"] -run = "NATIVE=true mise run lint:fast" +[tasks.build] +description = "Build the project" +run = "cargo build" -[tasks."setup:pre-commit-hook"] -description = "Install git pre-commit hook that runs native linting" -run = "mise generate git-pre-commit --write --task=pre-commit" +[tasks.test] +description = "Run tests" +run = "cargo test -q" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..4685804 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,123 @@ +use anyhow::Result; +use figment::{ + Figment, + providers::{Env, Format, Toml}, +}; +use serde::Deserialize; +use std::path::Path; + +use crate::registry; + +#[derive(Debug, Deserialize, Clone)] +#[serde(default)] +#[derive(Default)] +pub struct Config { + pub settings: Settings, + pub checks: ChecksConfig, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(default)] +pub struct Settings { + pub base_branch: String, + pub exclude: Vec, +} + +impl Default for Settings { + fn default() -> Self { + Self { + base_branch: "main".to_string(), + exclude: vec![], + } + } +} + +#[derive(Debug, Default, Deserialize, Clone)] +#[serde(default)] +pub struct ChecksConfig { + pub lychee: LycheeConfig, + // The alias allows the underscore form used in env var keys alongside the + // hyphenated form used in flint.toml. + #[serde(rename = "renovate-deps", alias = "renovate_deps")] + pub renovate_deps: RenovateDepsConfig, + #[serde(rename = "license-header", alias = "license_header")] + pub license_header: LicenseHeaderConfig, +} + +#[derive(Debug, Default, Deserialize, Clone)] +#[serde(default)] +pub struct LycheeConfig { + pub config: Option, + pub check_all_local: bool, +} + +#[derive(Debug, Default, Deserialize, Clone)] +#[serde(default)] +pub struct RenovateDepsConfig { + // Env var: FLINT_RENOVATE_DEPS_EXCLUDE_MANAGERS (JSON array, e.g. '["npm"]') + pub exclude_managers: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(default)] +pub struct LicenseHeaderConfig { + /// The text that must appear within the first `lines_to_check` lines of each file. + /// When empty (default), the check is disabled. + pub text: String, + /// Glob patterns for files to check (e.g. `["*.java", "*.kt"]`). + pub patterns: Vec, + /// How many lines from the top of each file to search. Default: 5. + pub lines_to_check: usize, +} + +impl Default for LicenseHeaderConfig { + fn default() -> Self { + Self { + text: String::new(), + patterns: vec![], + lines_to_check: 5, + } + } +} + +/// Builds env-var prefix → figment key-path mappings for every check in the registry. +/// e.g. "lychee" → ("lychee_", "checks.lychee.") +/// "renovate-deps" → ("renovate_deps_", "checks.renovate_deps.") +/// "ruff-format" → ("ruff_format_", "checks.ruff_format.") +/// Sorted longest-prefix-first so "ruff_format_" is matched before "ruff_". +fn check_env_sections() -> Vec<(String, String)> { + let mut sections: Vec<(String, String)> = registry::builtin() + .into_iter() + .map(|c| { + let n = c.name.replace('-', "_"); + (format!("{n}_"), format!("checks.{n}.")) + }) + .collect(); + // Dedup by prefix (multiple checks can share a name after normalisation is unlikely, + // but be safe) then sort longest-first to avoid short prefixes shadowing longer ones. + sections.sort_by(|a, b| b.0.len().cmp(&a.0.len())); + sections.dedup_by(|a, b| a.0 == b.0); + sections +} + +pub fn load(config_dir: &Path) -> Result { + let sections = check_env_sections(); + let cfg: Config = Figment::new() + .merge(Toml::file(config_dir.join("flint.toml"))) + // Flat FLINT_ env vars, no double-underscore separators: + // FLINT_BASE_BRANCH, FLINT_EXCLUDE → settings.* + // FLINT_LYCHEE_CONFIG, FLINT_LYCHEE_* → checks.lychee.* + // FLINT_RENOVATE_DEPS_EXCLUDE_MANAGERS → checks.renovate_deps.* + // New Special checks added to the registry get env support automatically. + .merge(Env::prefixed("FLINT_").map(move |k| { + let k = k.as_str(); + for (prefix, namespace) in §ions { + if let Some(rest) = k.strip_prefix(prefix.as_str()) { + return format!("{namespace}{rest}").into(); + } + } + format!("settings.{k}").into() + })) + .extract()?; + Ok(cfg) +} diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..9d786fe --- /dev/null +++ b/src/files.rs @@ -0,0 +1,175 @@ +use anyhow::{Context, Result}; +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::config::Config; +use crate::linters::renovate_deps::COMMITTED_PATHS; + +/// Files managed by flint itself — always excluded from generic linter checks. +const BUILTIN_EXCLUDES: &[&str] = COMMITTED_PATHS; + +#[derive(Clone)] +pub struct FileList { + pub files: Vec, + /// The merge base ref, used by project-scoped checks (e.g. golangci-lint). + pub merge_base: Option, + /// True when the file list contains all project files (explicit --full or no merge base). + /// Used by checks with a `full_cmd` to switch to a project-wide command. + pub full: bool, +} + +pub fn changed( + project_root: &Path, + cfg: &Config, + full: bool, + from_ref: Option<&str>, + to_ref: Option<&str>, +) -> Result { + let exclude = build_exclude_set(cfg); + + if full { + return all_files(project_root, &exclude); + } + + let merge_base = resolve_merge_base(project_root, cfg, from_ref)?; + + let files = if let Some(ref base) = merge_base { + let to = to_ref.unwrap_or("HEAD"); + collect_changed_files(project_root, &exclude, base, to)? + } else { + // No merge base (shallow clone etc.) — fall back to all files. + return all_files(project_root, &exclude); + }; + + Ok(FileList { + files, + merge_base, + full: false, + }) +} + +fn build_exclude_set(cfg: &Config) -> GlobSet { + let mut builder = GlobSetBuilder::new(); + for pattern in &cfg.settings.exclude { + match GlobBuilder::new(pattern).literal_separator(true).build() { + Ok(glob) => { + builder.add(glob); + } + Err(e) => { + eprintln!("flint: invalid exclude pattern {pattern:?}: {e}"); + } + } + } + builder.build().unwrap_or_default() +} + +fn resolve_merge_base( + project_root: &Path, + cfg: &Config, + from_ref: Option<&str>, +) -> Result> { + let base_ref = from_ref.unwrap_or(cfg.settings.base_branch.as_str()); + + // Try `origin/` first, then bare ``. + for candidate in [format!("origin/{base_ref}"), base_ref.to_string()] { + let out = Command::new("git") + .args(["merge-base", &candidate, "HEAD"]) + .current_dir(project_root) + .output() + .context("git merge-base")?; + if out.status.success() { + return Ok(Some( + String::from_utf8_lossy(&out.stdout).trim().to_string(), + )); + } + } + + Ok(None) +} + +fn collect_changed_files( + project_root: &Path, + exclude: &GlobSet, + base: &str, + to: &str, +) -> Result> { + let range = format!("{base}...{to}"); + let mut names: std::collections::BTreeSet = Default::default(); + + // Committed changes in the range. + for line in git_diff_names(project_root, &["--diff-filter=d", &range])? { + names.insert(line); + } + // Unstaged changes. + for line in git_diff_names(project_root, &["--diff-filter=d"])? { + names.insert(line); + } + // Staged changes. + for line in git_diff_names(project_root, &["--cached", "--diff-filter=d"])? { + names.insert(line); + } + + Ok(filter_names(project_root, exclude, names)) +} + +fn all_files(project_root: &Path, exclude: &GlobSet) -> Result { + let out = Command::new("git") + .args(["ls-files"]) + .current_dir(project_root) + .output() + .context("git ls-files")?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + anyhow::bail!("git ls-files failed ({}): {}", out.status, stderr.trim()); + } + + let names: std::collections::BTreeSet = String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::to_string) + .collect(); + + Ok(FileList { + files: filter_names(project_root, exclude, names), + merge_base: None, + full: true, + }) +} + +fn git_diff_names(project_root: &Path, extra_args: &[&str]) -> Result> { + let mut args = vec!["diff", "--name-only"]; + args.extend_from_slice(extra_args); + let out = Command::new("git") + .args(&args) + .current_dir(project_root) + .output() + .context("git diff --name-only")?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + anyhow::bail!( + "git diff --name-only failed ({}): {}", + out.status, + stderr.trim() + ); + } + + Ok(String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::to_string) + .collect()) +} + +fn filter_names( + project_root: &Path, + exclude: &GlobSet, + names: std::collections::BTreeSet, +) -> Vec { + names + .into_iter() + .filter(|name| !BUILTIN_EXCLUDES.contains(&name.as_str())) + .filter(|name| !exclude.is_match(name)) + .map(|name| project_root.join(name)) + .collect() +} diff --git a/src/hook.rs b/src/hook.rs new file mode 100644 index 0000000..749afd1 --- /dev/null +++ b/src/hook.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use std::path::Path; + +const HOOK_CONTENT: &str = "#!/bin/sh\n\ +# Installed by flint — run `flint hook install` to reinstall\n\ +mise exec -- flint run --fix --fast-only\n"; + +/// Writes `.git/hooks/pre-commit`. Skips silently if the hook already exists. +pub fn install(project_root: &Path) -> Result<()> { + let git_dir = project_root.join(".git"); + if !git_dir.exists() { + anyhow::bail!("not a git repository (no .git directory found)"); + } + let hooks_dir = git_dir.join("hooks"); + std::fs::create_dir_all(&hooks_dir)?; + let hook_path = hooks_dir.join("pre-commit"); + if hook_path.exists() { + println!("pre-commit hook already installed"); + return Ok(()); + } + std::fs::write(&hook_path, HOOK_CONTENT)?; + set_executable(&hook_path)?; + println!("installed pre-commit hook (.git/hooks/pre-commit)"); + Ok(()) +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> Result<()> { + Ok(()) +} diff --git a/src/init/detection.rs b/src/init/detection.rs new file mode 100644 index 0000000..992d097 --- /dev/null +++ b/src/init/detection.rs @@ -0,0 +1,159 @@ +use anyhow::{Context, Result}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::process::Command; + +use crate::registry::{Category, Check, OBSOLETE_KEYS}; + +use super::{LinterGroup, install_key}; + +/// Returns `true` if the repo contains at least one file matching any of the +/// check's patterns. Checks with no patterns (project-scope specials like +/// lychee) are always considered present. +pub(super) fn files_present(check: &Check, present_patterns: &HashSet) -> bool { + check.patterns.is_empty() + || check + .patterns + .iter() + .any(|p| *p == "*" || present_patterns.contains(*p)) +} + +/// Runs `git ls-files -- ` for every unique pattern in the registry +/// and returns the set of patterns that produced at least one result. +pub(super) fn detect_present_patterns( + project_root: &Path, + registry: &[Check], +) -> Result> { + let all_patterns: HashSet<&str> = registry + .iter() + .flat_map(|c| c.patterns.iter().copied()) + .filter(|p| *p != "*") + .collect(); + + let mut present = HashSet::new(); + for pattern in all_patterns { + let out = Command::new("git") + .args(["ls-files", "--", pattern]) + .current_dir(project_root) + .output() + .context("git ls-files")?; + if !out.stdout.is_empty() { + present.insert(pattern.to_string()); + } + } + Ok(present) +} + +/// Returns the set of keys currently declared in `[tools]`. +pub(super) fn parse_tool_keys(content: &str) -> HashSet { + let value: toml::Value = match toml::from_str(content) { + Ok(v) => v, + Err(_) => return HashSet::new(), + }; + value + .get("tools") + .and_then(|v| v.as_table()) + .map(|t| t.keys().cloned().collect()) + .unwrap_or_default() +} + +/// Returns `true` if the `[tools]` entry for `key` exists and its `components` +/// field is absent or differs from `required`. Used to detect entries that need +/// upgrading (missing components) or correcting (wrong components). +#[cfg(test)] +pub(super) fn entry_components_differ(content: &str, key: &str, required: &str) -> bool { + let doc: toml_edit::DocumentMut = match content.parse() { + Ok(d) => d, + Err(_) => return false, + }; + let tools = match doc.get("tools").and_then(|t| t.as_table()) { + Some(t) => t, + None => return false, + }; + match tools.get(key) { + Some(item) => match item.as_value() { + Some(toml_edit::Value::InlineTable(tbl)) => { + tbl.get("components").and_then(|v| v.as_str()) != Some(required) + } + Some(toml_edit::Value::String(_)) => true, + _ => false, + }, + None => false, + } +} + +/// Returns the `components` string currently set for `key` in the `[tools]` section, +/// or `None` if the key is absent, is a plain string entry, or has no `components` field. +pub(super) fn get_entry_components(content: &str, key: &str) -> Option { + let doc: toml_edit::DocumentMut = content.parse().ok()?; + let tools = doc.get("tools")?.as_table()?; + match tools.get(key)?.as_value()? { + toml_edit::Value::InlineTable(tbl) => tbl.get("components")?.as_str().map(str::to_string), + _ => None, + } +} + +/// Returns the subset of `OBSOLETE_KEYS` whose old key is present in `current_tool_keys`. +pub(super) fn detect_obsolete_keys( + current_tool_keys: &HashSet, +) -> Vec<(&'static str, &'static str)> { + OBSOLETE_KEYS + .iter() + .filter(|(old, _)| current_tool_keys.contains(*old)) + .copied() + .collect() +} + +/// Builds one `LinterGroup` per install key, covering all checks whose file patterns +/// are present in the repo or whose key is already installed. +pub(super) fn build_linter_groups<'a>( + registry: &'a [Check], + present_patterns: &HashSet, + current_tool_keys: &HashSet, + current_content: &str, + default_categories: &HashSet, +) -> Vec> { + let mut by_key: HashMap<&'static str, Vec<&'a Check>> = HashMap::new(); + for check in registry { + let key = match install_key(check) { + Some(k) => k, + None => continue, + }; + if files_present(check, present_patterns) || current_tool_keys.contains(key) { + by_key.entry(key).or_default().push(check); + } + } + + let mut groups: Vec> = by_key + .into_iter() + .map(|(key, mut checks)| { + checks.sort_by_key(|c| c.name); + let installed = current_tool_keys.contains(key); + let current_components = if installed { + get_entry_components(current_content, key) + } else { + None + }; + // Pre-select each check individually: select if its category is in the + // default set and its patterns are present, OR if the key is already installed. + let check_selected: Vec = checks + .iter() + .map(|c| { + let suggested = default_categories.contains(&c.category) + && files_present(c, present_patterns); + suggested || installed + }) + .collect(); + LinterGroup { + key, + checks, + check_selected, + installed, + current_components, + } + }) + .collect(); + + groups.sort_by_key(|g| g.checks.first().map_or(g.key, |c| c.name)); + groups +} diff --git a/src/init/generation.rs b/src/init/generation.rs new file mode 100644 index 0000000..5229e53 --- /dev/null +++ b/src/init/generation.rs @@ -0,0 +1,842 @@ +use anyhow::{Context, Result}; +use std::io::{self, BufRead, Write}; +use std::path::Path; +use std::process::Command; + +use super::LinterGroup; +use super::detection::parse_tool_keys; + +/// Returns the renovate preset entry to inject, e.g. `github>grafana/flint#v0.9.2`. +/// Pre-release suffixes are stripped so dev builds produce a valid tag reference. +pub(super) fn flint_preset() -> String { + let ver = env!("CARGO_PKG_VERSION"); + let ver = ver.split('-').next().unwrap_or(ver); + format!("github>grafana/flint#v{ver}") +} + +/// Adds the flint renovate preset to the `extends` array in a renovate config file. +/// Works for both JSON and JSON5. If an unpinned or differently-pinned flint entry +/// already exists, it is replaced in-place rather than duplicated. +/// Returns `true` if the file was changed. +pub(super) fn patch_renovate_extends(path: &Path) -> Result { + let entry = flint_preset(); + let content = std::fs::read_to_string(path)?; + + if content.contains(&entry) { + return Ok(false); + } + + // If an existing flint entry (any pin) is present, replace it in-place. + const FLINT_ENTRY_PREFIX: &str = "\"github>grafana/flint"; + let new_content = if let Some(pos) = content.find(FLINT_ENTRY_PREFIX) { + let after_open = pos + 1; // skip leading " + let close = content[after_open..] + .find('"') + .context("unclosed quote in existing flint preset entry")?; + let end = after_open + close + 1; // position after closing " + format!("{}\"{}\"{}", &content[..pos], entry, &content[end..]) + } else { + add_to_extends(&content, &entry) + .with_context(|| format!("failed to patch extends in {}", path.display()))? + }; + + std::fs::write(path, new_content)?; + Ok(true) +} + +/// Text-based insertion of `entry` into the `extends` array. +/// Works for both JSON (`"extends": [`) and JSON5 (`extends: [`). +fn add_to_extends(content: &str, entry: &str) -> Result { + let re = regex::Regex::new(r#"(?:"extends"|extends)\s*:\s*\["#).unwrap(); + + if let Some(m) = re.find(content) { + let bracket_pos = m.end() - 1; // index of '[' + let inside_start = bracket_pos + 1; + + let close_offset = content[inside_start..] + .find(']') + .context("extends array has no closing ]")?; + let close_pos = inside_start + close_offset; + let inside = &content[inside_start..close_pos]; + + if inside.contains('\n') { + // Multiline: detect indent from first non-empty line, insert at top + let indent = inside + .lines() + .find(|l| !l.trim().is_empty()) + .map(|l| " ".repeat(l.len() - l.trim_start().len())) + .unwrap_or_else(|| " ".to_string()); + Ok(format!( + "{}\n{}\"{}\"{}{}", + &content[..inside_start], + indent, + entry, + ",", + &content[inside_start..] + )) + } else { + // Single-line (empty or not): prepend entry + let sep = if inside.trim().is_empty() { "" } else { ", " }; + Ok(format!( + "{}\"{}\"{}{}", + &content[..inside_start], + entry, + sep, + &content[inside_start..] + )) + } + } else { + // No extends key — add after the opening { + let open = content + .find('{') + .context("no opening { in renovate config")?; + let (before, after) = content.split_at(open + 1); + Ok(format!( + "{}\n \"extends\": [\"{}\"],{}", + before, entry, after + )) + } +} + +/// Runs `mise use --pin @latest` in the project directory to add a tool +/// with a pinned version. Returns `true` if the key was written to the config +/// (checked by re-reading the file), ignoring non-zero exit codes that arise +/// from post-write steps like shim rebuilds failing in restricted environments. +fn pin_tool_via_mise(project_root: &Path, key: &str) -> bool { + let mise_path = project_root.join("mise.toml"); + let before = std::fs::read_to_string(&mise_path).unwrap_or_default(); + + let _ = Command::new("mise") + .args(["use", "--pin", &format!("{key}@latest")]) + .current_dir(project_root) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + + // Success = the key is now present in the config (regardless of exit code). + let after = std::fs::read_to_string(&mise_path).unwrap_or_default(); + after != before && parse_tool_keys(&after).contains(key) +} + +pub(super) fn apply_changes( + path: &Path, + current_content: &str, + to_add: &[(String, Option)], + to_remove: &[String], + to_upgrade: &[(String, String)], +) -> Result<()> { + let project_root = path.parent().unwrap_or(path); + + // Pin new tools via `mise use --pin`. For tools where mise succeeds the + // file is already updated; we still open the file below to handle removals, + // upgrades, and component additions. + let mut pinned_via_mise: std::collections::HashSet = std::collections::HashSet::new(); + for (key, _) in to_add { + if pin_tool_via_mise(project_root, key) { + pinned_via_mise.insert(key.clone()); + } else { + eprintln!(" warning: could not pin {key} via mise — writing \"latest\""); + } + } + + // Re-read the file only if mise actually modified it. + let current_content: String = if pinned_via_mise.is_empty() { + current_content.to_string() + } else { + std::fs::read_to_string(path).unwrap_or_else(|_| current_content.to_string()) + }; + let mut doc: toml_edit::DocumentMut = current_content + .parse() + .unwrap_or_else(|_| toml_edit::DocumentMut::new()); + + // Ensure [tools] table exists. + if !doc.contains_key("tools") { + doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new())); + } + let tools = doc["tools"] + .as_table_mut() + .context("[tools] is not a table")?; + + for key in to_remove { + tools.remove(key.as_str()); + } + + for (key, components) in to_add { + let already_pinned = pinned_via_mise.contains(key.as_str()); + match components { + Some(comps) => { + // If mise already wrote a plain-string version, upgrade to inline + // table to attach the components field. + let existing_version = if already_pinned { + tools + .get(key.as_str()) + .and_then(|i| i.as_value()) + .and_then(|v| match v { + toml_edit::Value::String(s) => Some(s.value().to_string()), + toml_edit::Value::InlineTable(t) => t + .get("version") + .and_then(|v| v.as_str()) + .map(str::to_string), + _ => None, + }) + .unwrap_or_else(|| "latest".to_string()) + } else { + "latest".to_string() + }; + let mut tbl = toml_edit::InlineTable::new(); + tbl.insert("version", toml_edit::Value::from(existing_version.as_str())); + tbl.insert("components", toml_edit::Value::from(comps.as_str())); + tools.insert( + key.as_str(), + toml_edit::Item::Value(toml_edit::Value::InlineTable(tbl)), + ); + } + None => { + if !already_pinned { + tools.insert(key.as_str(), toml_edit::value("latest")); + } + // Already pinned by mise — leave the entry as-is. + } + } + } + + // Upgrade existing entries: preserve the current version, update components. + for (key, components) in to_upgrade { + let existing_version = tools + .get(key.as_str()) + .and_then(|item| item.as_value()) + .and_then(|v| match v { + toml_edit::Value::String(s) => Some(s.value().to_string()), + toml_edit::Value::InlineTable(tbl) => tbl + .get("version") + .and_then(|v| v.as_str()) + .map(str::to_string), + _ => None, + }) + .unwrap_or_else(|| "latest".to_string()); + + let mut tbl = toml_edit::InlineTable::new(); + tbl.insert("version", toml_edit::Value::from(existing_version.as_str())); + tbl.insert("components", toml_edit::Value::from(components.as_str())); + tools.insert( + key.as_str(), + toml_edit::Item::Value(toml_edit::Value::InlineTable(tbl)), + ); + } + + std::fs::write(path, doc.to_string())?; + Ok(()) +} + +const FLINT_V1_URL_PREFIX: &str = "https://raw.githubusercontent.com/grafana/flint/"; + +pub(super) struct V1Removal { + /// Task keys that were removed from `[tasks]`. + pub removed_tasks: Vec, + /// Whether `RENOVATE_TRACKED_DEPS_EXCLUDE` was removed from `[env]`. + pub removed_renovate_env: bool, + /// The manager list from `RENOVATE_TRACKED_DEPS_EXCLUDE`, split on commas, if it was present. + pub renovate_exclude_managers: Option>, +} + +/// Removes v1 HTTP task entries (tasks whose `file` value starts with the +/// flint raw-GitHub URL) and, when a renovate-deps v1 task is present, +/// also removes `RENOVATE_TRACKED_DEPS_EXCLUDE` from `[env]`. +/// +/// Returns details about what was removed. Writes the file only when changed. +pub(super) fn remove_v1_tasks(path: &Path) -> Result { + let content = std::fs::read_to_string(path).unwrap_or_default(); + let mut doc: toml_edit::DocumentMut = content + .parse() + .unwrap_or_else(|_| toml_edit::DocumentMut::new()); + + let mut removed_tasks: Vec = Vec::new(); + let mut has_v1_renovate = false; + + if let Some(tasks) = doc.get_mut("tasks").and_then(|t| t.as_table_mut()) { + let keys_to_remove: Vec = tasks + .iter() + .filter_map(|(key, item)| { + let file_val = item + .as_table() + .and_then(|t| t.get("file")) + .and_then(|v| v.as_str())?; + if file_val.starts_with(FLINT_V1_URL_PREFIX) { + Some(key.to_string()) + } else { + None + } + }) + .collect(); + + for key in keys_to_remove { + // Check if it's a renovate-deps task before removing. + if let Some(file_val) = tasks + .get(&key) + .and_then(|i| i.as_table()) + .and_then(|t| t.get("file")) + .and_then(|v| v.as_str()) + && file_val.contains("renovate-deps") + { + has_v1_renovate = true; + } + tasks.remove(&key); + removed_tasks.push(key); + } + } + + let mut removed_renovate_env = false; + let mut renovate_exclude_managers: Option> = None; + if has_v1_renovate + && let Some(env) = doc.get_mut("env").and_then(|t| t.as_table_mut()) + && let Some(val) = env + .get("RENOVATE_TRACKED_DEPS_EXCLUDE") + .and_then(|v| v.as_str()) + { + renovate_exclude_managers = Some( + val.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(), + ); + env.remove("RENOVATE_TRACKED_DEPS_EXCLUDE"); + removed_renovate_env = true; + } + + if !removed_tasks.is_empty() || removed_renovate_env { + std::fs::write(path, doc.to_string())?; + } + + removed_tasks.sort(); + Ok(V1Removal { + removed_tasks, + removed_renovate_env, + renovate_exclude_managers, + }) +} + +/// Returns true if any currently-selected check has `Category::Slow`. +pub(super) fn has_slow_selected(groups: &[LinterGroup]) -> bool { + use crate::registry::Category; + groups.iter().any(|g| { + g.checks + .iter() + .zip(&g.check_selected) + .any(|(c, &sel)| sel && c.category == Category::Slow) + }) +} + +/// Reads the default branch for `origin` from git, falling back to `"main"`. +pub(super) fn detect_base_branch(project_root: &Path) -> String { + Command::new("git") + .args(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]) + .current_dir(project_root) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|s| s.trim().strip_prefix("origin/").map(str::to_string)) + .unwrap_or_else(|| "main".to_string()) +} + +/// Reads `FLINT_CONFIG_DIR` from the `[env]` section of a mise.toml string, if present. +pub(super) fn get_existing_config_dir(content: &str) -> Option { + let doc: toml_edit::DocumentMut = content.parse().ok()?; + doc.get("env")? + .as_table()? + .get("FLINT_CONFIG_DIR")? + .as_str() + .map(str::to_string) +} + +/// Asks where `flint.toml` should live. Skips the prompt when `--yes` or when +/// `FLINT_CONFIG_DIR` is already set in the current mise.toml. +/// +/// Returns a path relative to the project root (e.g. `".github/config"`). +pub(super) fn prompt_config_dir(existing: Option<&str>, yes: bool) -> Result { + if let Some(dir) = existing { + return Ok(dir.to_string()); + } + if yes { + return Ok(".github/config".to_string()); + } + + const CHOICES: &[&str] = &[".github/config", ".github", ".", "other…"]; + println!("Where should flint.toml live?\n"); + for (i, choice) in CHOICES.iter().enumerate() { + println!(" {}) {}", i + 1, choice); + } + print!("\nChoice [1]: "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + let input = input.trim(); + + let idx: usize = if input.is_empty() { + 0 + } else { + input.parse::().unwrap_or(1).saturating_sub(1) + }; + + if idx == CHOICES.len() - 1 { + print!("Config dir path: "); + io::stdout().flush()?; + let mut path = String::new(); + io::stdin().lock().read_line(&mut path)?; + Ok(path.trim().to_string()) + } else { + Ok(CHOICES[idx.min(CHOICES.len() - 2)].to_string()) + } +} + +/// Writes a skeleton `flint.toml` in `config_dir`. Creates the directory if needed. +/// Returns `true` if the file was written, `false` if it already existed. +/// +/// `exclude_managers`: when `Some`, populates `exclude_managers` in `[checks.renovate-deps]` +/// with the given list (migrated from `RENOVATE_TRACKED_DEPS_EXCLUDE`). When `None` and +/// `has_renovate` is true, writes a commented-out placeholder instead. +pub(super) fn generate_flint_toml( + config_dir: &Path, + base_branch: &str, + has_renovate: bool, + exclude_managers: Option<&[String]>, +) -> Result { + let toml_path = config_dir.join("flint.toml"); + if toml_path.exists() { + return Ok(false); + } + std::fs::create_dir_all(config_dir)?; + let mut content = String::from("[settings]\n"); + if base_branch != "main" { + content.push_str(&format!("base_branch = \"{base_branch}\"\n")); + } + content.push_str("# exclude = \"CHANGELOG\\\\.md\"\n"); + content.push_str("# exclude_paths = []\n"); + if has_renovate { + content.push_str("\n[checks.renovate-deps]\n"); + match exclude_managers { + Some(managers) if !managers.is_empty() => { + let list = managers + .iter() + .map(|m| format!("\"{m}\"")) + .collect::>() + .join(", "); + content.push_str(&format!("exclude_managers = [{list}]\n")); + } + _ => content.push_str("# exclude_managers = []\n"), + } + } + std::fs::write(&toml_path, &content)?; + println!(" wrote {}", toml_path.display()); + Ok(true) +} + +/// Generates `.markdownlint.jsonc` in the project root if it does not already exist +/// and markdownlint-cli2 is being set up. +/// Returns `true` if the file was written. +pub(super) fn generate_markdownlint_config(project_root: &Path) -> Result { + let path = project_root.join(".markdownlint.jsonc"); + if path.exists() { + return Ok(false); + } + let content = "{\n // Disable line-length enforcement — long lines are common in tables and code links\n \"MD013\": false,\n}\n"; + std::fs::write(&path, content)?; + println!(" wrote {}", path.display()); + Ok(true) +} + +/// Generates `.github/workflows/lint.yml` if it does not already exist. +/// Returns `true` if the file was written. +pub(super) fn generate_lint_workflow(project_root: &Path, base_branch: &str) -> Result { + let workflows_dir = project_root.join(".github/workflows"); + let workflow_path = workflows_dir.join("lint.yml"); + if workflow_path.exists() { + return Ok(false); + } + std::fs::create_dir_all(&workflows_dir)?; + let content = format!( + r#"name: Lint + +on: + push: + branches: [{base_branch}] + pull_request: + branches: [{base_branch}] + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup mise + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + with: + version: v2026.4.1 + sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd + + - name: Lint + env: + GITHUB_TOKEN: ${{{{ github.token }}}} + GITHUB_HEAD_SHA: ${{{{ github.event.pull_request.head.sha }}}} + run: mise run lint +"# + ); + std::fs::write(&workflow_path, content)?; + println!(" wrote {}", workflow_path.display()); + Ok(true) +} + +/// Adds a `[tasks.]` entry only when it is not already present. +/// Returns `true` if an entry was added. +fn add_task_if_absent( + tasks: &mut toml_edit::Table, + name: &str, + description: &str, + run: &str, +) -> bool { + if tasks.contains_key(name) { + return false; + } + write_task(tasks, name, description, run); + true +} + +/// Unconditionally writes a `[tasks.]` entry (adds or replaces). +fn write_task(tasks: &mut toml_edit::Table, name: &str, description: &str, run: &str) { + let mut t = toml_edit::Table::new(); + t.insert("description", toml_edit::value(description)); + t.insert("run", toml_edit::value(run)); + tasks.insert(name, toml_edit::Item::Table(t)); +} + +/// Returns `true` when the named task has a `depends` array where at least one +/// entry is in `removed_tasks`. Used to detect tasks made stale by v1 removal. +fn task_has_removed_dep(tasks: &toml_edit::Table, name: &str, removed: &[String]) -> bool { + let Some(item) = tasks.get(name) else { + return false; + }; + let Some(task) = item.as_table() else { + return false; + }; + let Some(depends) = task.get("depends").and_then(|v| v.as_array()) else { + return false; + }; + depends.iter().any(|v| { + v.as_str() + .map(|s| removed.iter().any(|r| r == s)) + .unwrap_or(false) + }) +} + +/// Adds `[env] FLINT_CONFIG_DIR` and the standard `lint*` tasks to `mise.toml`, +/// skipping any that are already present. +/// +/// When `removed_v1_tasks` is non-empty, standard tasks whose `depends` reference +/// any of those removed tasks are replaced (they became stale after v1 removal). +/// +/// Returns `true` if the file was changed. +pub(super) fn apply_env_and_tasks( + mise_path: &Path, + config_dir_rel: &str, + has_slow: bool, + removed_v1_tasks: &[String], +) -> Result { + let content = std::fs::read_to_string(mise_path).unwrap_or_default(); + let mut doc: toml_edit::DocumentMut = content + .parse() + .unwrap_or_else(|_| toml_edit::DocumentMut::new()); + let mut changed = false; + + // [env] — add FLINT_CONFIG_DIR if absent + { + if !doc.contains_key("env") { + doc.insert("env", toml_edit::Item::Table(toml_edit::Table::new())); + } + let env = doc["env"].as_table_mut().context("[env] is not a table")?; + if !env.contains_key("FLINT_CONFIG_DIR") { + env.insert("FLINT_CONFIG_DIR", toml_edit::value(config_dir_rel)); + changed = true; + } + } + + // [tasks] — add lint / lint:fix / (lint:pre-commit) + { + if !doc.contains_key("tasks") { + doc.insert("tasks", toml_edit::Item::Table(toml_edit::Table::new())); + } + let tasks = doc["tasks"] + .as_table_mut() + .context("[tasks] is not a table")?; + + // Replace the lint task when it was made stale by v1 removal (its depends + // referenced removed tasks and would now fail). Otherwise add if absent. + let lint_stale = task_has_removed_dep(tasks, "lint", removed_v1_tasks); + if lint_stale { + write_task(tasks, "lint", "Run all lints", "flint run"); + changed = true; + } else { + changed |= add_task_if_absent(tasks, "lint", "Run all lints", "flint run"); + } + + changed |= add_task_if_absent(tasks, "lint:fix", "Auto-fix lint issues", "flint run --fix"); + if has_slow { + changed |= add_task_if_absent( + tasks, + "lint:pre-commit", + "Fast auto-fix lint (skips slow checks) — for pre-commit/pre-push hooks", + "flint run --fix --fast-only", + ); + } + } + + if changed { + std::fs::write(mise_path, doc.to_string())?; + } + Ok(changed) +} + +/// Offers to install the git pre-commit hook via `flint hook install`. +/// Prompts the user unless `yes` is true. Silently skips if the hook is already installed. +pub(super) fn maybe_install_hook(project_root: &Path, yes: bool) -> Result<()> { + let hook_path = project_root.join(".git/hooks/pre-commit"); + if hook_path.exists() { + return Ok(()); + } + + let install = if yes { + true + } else { + print!( + "Install pre-commit hook (runs `flint run --fix --fast-only` before each commit)? [Y/n] " + ); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + !input.trim().eq_ignore_ascii_case("n") + }; + + if install { + crate::hook::install(project_root)?; + } + Ok(()) +} + +#[cfg(test)] +mod v1_removal_tests { + use super::remove_v1_tasks; + + fn write_tmp(content: &str) -> tempfile::NamedTempFile { + let f = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(f.path(), content).unwrap(); + f + } + + #[test] + fn removes_v1_http_tasks() { + let content = r#" +[tools] +lychee = "latest" + +[tasks."lint:links"] +description = "Check for broken links" +file = "https://raw.githubusercontent.com/grafana/flint/abc123/tasks/lint/links.sh" + +[tasks."lint:renovate-deps"] +description = "Check renovate deps" +file = "https://raw.githubusercontent.com/grafana/flint/abc123/tasks/lint/renovate-deps.py" + +[tasks.build] +description = "Build the project" +run = "cargo build" +"#; + let tmp = write_tmp(content); + let result = remove_v1_tasks(tmp.path()).unwrap(); + assert_eq!(result.removed_tasks, ["lint:links", "lint:renovate-deps"]); + assert!(!result.removed_renovate_env); + let after = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(!after.contains("lint:links")); + assert!(!after.contains("lint:renovate-deps")); + assert!(after.contains("[tasks.build]"), "non-v1 tasks preserved"); + } + + #[test] + fn removes_renovate_env_when_v1_renovate_task_present() { + let content = r#" +[env] +RENOVATE_TRACKED_DEPS_EXCLUDE = "github-actions, github-runners" + +[tasks."lint:renovate-deps"] +description = "Check renovate deps" +file = "https://raw.githubusercontent.com/grafana/flint/abc123/tasks/lint/renovate-deps.py" +"#; + let tmp = write_tmp(content); + let result = remove_v1_tasks(tmp.path()).unwrap(); + assert_eq!(result.removed_tasks, ["lint:renovate-deps"]); + assert!(result.removed_renovate_env); + assert_eq!( + result.renovate_exclude_managers, + Some(vec![ + "github-actions".to_string(), + "github-runners".to_string() + ]) + ); + let after = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(!after.contains("RENOVATE_TRACKED_DEPS_EXCLUDE")); + } + + #[test] + fn does_not_remove_renovate_env_without_v1_renovate_task() { + let content = r#" +[env] +RENOVATE_TRACKED_DEPS_EXCLUDE = "github-actions" + +[tasks."lint:links"] +description = "Check links" +file = "https://raw.githubusercontent.com/grafana/flint/abc123/tasks/lint/links.sh" +"#; + let tmp = write_tmp(content); + let result = remove_v1_tasks(tmp.path()).unwrap(); + assert_eq!(result.removed_tasks, ["lint:links"]); + assert!(!result.removed_renovate_env); + let after = std::fs::read_to_string(tmp.path()).unwrap(); + assert!( + after.contains("RENOVATE_TRACKED_DEPS_EXCLUDE"), + "env var preserved when no renovate task" + ); + } + + #[test] + fn no_op_when_no_v1_tasks() { + let content = "[tools]\nlychee = \"latest\"\n"; + let tmp = write_tmp(content); + let original_mtime = std::fs::metadata(tmp.path()).unwrap().modified().unwrap(); + let result = remove_v1_tasks(tmp.path()).unwrap(); + assert!(result.removed_tasks.is_empty()); + assert!(!result.removed_renovate_env); + // File should not have been written. + let new_mtime = std::fs::metadata(tmp.path()).unwrap().modified().unwrap(); + assert_eq!( + original_mtime, new_mtime, + "file unchanged when nothing to remove" + ); + } + + #[test] + fn ignores_non_flint_http_tasks() { + let content = r#" +[tasks."lint:something"] +file = "https://raw.githubusercontent.com/some-other-org/some-repo/abc123/task.sh" +"#; + let tmp = write_tmp(content); + let result = remove_v1_tasks(tmp.path()).unwrap(); + assert!(result.removed_tasks.is_empty()); + } +} + +#[cfg(test)] +mod extends_tests { + use super::{add_to_extends, patch_renovate_extends}; + + fn write_tmp(content: &str) -> tempfile::NamedTempFile { + let f = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(f.path(), content).unwrap(); + f + } + + #[test] + fn replaces_unpinned_flint_entry_in_place() { + let input = r#"{ extends: ["config:recommended", "github>grafana/flint"] }"#; + let tmp = write_tmp(input); + let changed = patch_renovate_extends(tmp.path()).unwrap(); + assert!(changed); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!( + result.contains("github>grafana/flint#v"), + "pinned entry written: {result}" + ); + // Only one flint entry — no duplicate + assert_eq!( + result.matches("grafana/flint").count(), + 1, + "no duplicate: {result}" + ); + assert!( + !result.contains("\"github>grafana/flint\""), + "unpinned removed: {result}" + ); + } + + #[test] + fn replaces_differently_pinned_flint_entry() { + let input = r#"{ extends: ["config:recommended", "github>grafana/flint#v0.5.0"] }"#; + let tmp = write_tmp(input); + let changed = patch_renovate_extends(tmp.path()).unwrap(); + assert!(changed); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(!result.contains("v0.5.0"), "old pin removed: {result}"); + assert_eq!( + result.matches("grafana/flint").count(), + 1, + "no duplicate: {result}" + ); + } + + #[test] + fn no_op_when_already_pinned_to_current_version() { + let entry = super::flint_preset(); + let input = format!(r#"{{ extends: ["config:recommended", "{entry}"] }}"#); + let tmp = write_tmp(&input); + let changed = patch_renovate_extends(tmp.path()).unwrap(); + assert!(!changed); + } + + #[test] + fn adds_to_single_line_extends() { + let input = r#"{ "extends": ["config:recommended"], "other": 1 }"#; + let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); + assert!(result.contains(r#"["github>grafana/flint#v0.9.2", "config:recommended"]"#)); + } + + #[test] + fn adds_to_json5_unquoted_key() { + let input = "{\n extends: [\"config:recommended\"],\n}\n"; + let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); + assert!(result.contains(r#""github>grafana/flint#v0.9.2", "config:recommended""#)); + } + + #[test] + fn adds_to_multiline_extends() { + let input = "{\n extends: [\n \"config:recommended\",\n \"other\"\n ]\n}\n"; + let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); + assert!(result.contains("\"github>grafana/flint#v0.9.2\",")); + // Entry should appear before existing entries + let flint_pos = result.find("grafana/flint").unwrap(); + let existing_pos = result.find("config:recommended").unwrap(); + assert!(flint_pos < existing_pos); + } + + #[test] + fn adds_extends_when_absent() { + let input = "{\n \"branchPrefix\": \"renovate/\"\n}\n"; + let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); + assert!(result.contains("\"extends\"")); + assert!(result.contains("github>grafana/flint#v0.9.2")); + } + + #[test] + fn adds_to_empty_extends_array() { + let input = r#"{ "extends": [] }"#; + let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); + assert!(result.contains(r#"["github>grafana/flint#v0.9.2"]"#)); + } +} diff --git a/src/init/mod.rs b/src/init/mod.rs new file mode 100644 index 0000000..c098338 --- /dev/null +++ b/src/init/mod.rs @@ -0,0 +1,742 @@ +use anyhow::Result; +#[cfg(test)] +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::Path; + +use crate::registry::{Category, Check, builtin}; + +mod detection; +mod generation; +mod ui; + +use detection::{ + build_linter_groups, detect_obsolete_keys, detect_present_patterns, parse_tool_keys, +}; +use generation::{ + apply_changes, apply_env_and_tasks, detect_base_branch, flint_preset, generate_flint_toml, + generate_lint_workflow, generate_markdownlint_config, get_existing_config_dir, + has_slow_selected, maybe_install_hook, patch_renovate_extends, prompt_config_dir, + remove_v1_tasks, +}; +use ui::{interactive_select_linters, select_categories_arrow}; + +/// Linter profile — shorthand for `--profile` CLI flag; maps to a category set. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum Profile { + /// Primary language linters only (ruff, cargo-clippy, golangci-lint, …). + Lang, + /// Lang + supplementary checks + fast general tools (shellcheck, prettier, codespell, …). + Default, + /// Default + slow linters (renovate-deps). + Comprehensive, +} + +fn profile_to_categories(profile: Profile) -> HashSet { + match profile { + Profile::Lang => [Category::Lang].into(), + Profile::Default => [Category::Lang, Category::Style, Category::Default].into(), + Profile::Comprehensive => [ + Category::Lang, + Category::Style, + Category::Default, + Category::Slow, + ] + .into(), + } +} + +/// Desired tools for a profile: maps each mise tool key to its optional components string. +#[cfg(test)] +type DesiredTools = HashMap>; + +// One entry per install key — groups all checks sharing that key. +struct LinterGroup<'a> { + key: &'static str, + checks: Vec<&'a Check>, // sorted by name + check_selected: Vec, // parallel to checks + installed: bool, + current_components: Option, +} + +impl LinterGroup<'_> { + fn any_selected(&self) -> bool { + self.check_selected.iter().any(|&s| s) + } + + /// Components string to write for the currently selected checks, e.g. `"clippy,rustfmt"`. + /// Returns `None` when no selected check carries a component requirement. + fn selected_components(&self) -> Option { + let comps: Vec<&'static str> = self + .checks + .iter() + .zip(&self.check_selected) + .filter_map(|(c, &sel)| if sel { c.mise_install_components } else { None }) + .collect(); + if comps.is_empty() { + None + } else { + Some(comps.join(",")) + } + } + + fn action(&self) -> &'static str { + if self.any_selected() { + if !self.installed { + "add" + } else if self.selected_components() != self.current_components { + "upgrade" + } else { + "keep" + } + } else if self.installed { + "remove" + } else { + "" + } + } +} + +// --- Category selection (step 1) --- + +struct CategoryItem { + selected: bool, + category: Category, + label: &'static str, +} + +fn default_category_items() -> Vec { + vec![ + CategoryItem { + selected: true, + category: Category::Lang, + label: "lang — primary language linters (ruff, cargo-clippy, golangci-lint, …)", + }, + CategoryItem { + selected: true, + category: Category::Style, + label: "style — supplementary checks (shellcheck, actionlint, hadolint, …)", + }, + CategoryItem { + selected: true, + category: Category::Default, + label: "general — general tools (codespell, ec, lychee, …)", + }, + CategoryItem { + selected: false, + category: Category::Slow, + label: "slow — slow linters (renovate-deps)", + }, + ] +} + +pub fn run(project_root: &Path, profile_arg: Option, yes: bool) -> Result<()> { + println!( + "Tip: flint init detects languages from tracked files (`git ls-files`). \ +Add and stage your source files before running init so the detection is accurate." + ); + println!(); + + let registry = builtin(); + let present_patterns = detect_present_patterns(project_root, ®istry)?; + + // Step 1: determine which categories set the initial pre-selection. + let default_categories: HashSet = if let Some(profile) = profile_arg { + profile_to_categories(profile) + } else if yes { + profile_to_categories(Profile::Default) + } else { + let mut cat_items = default_category_items(); + if !select_categories_arrow(&mut cat_items)? { + println!("Aborted."); + return Ok(()); + } + cat_items + .iter() + .filter(|i| i.selected) + .map(|i| i.category) + .collect() + }; + + let mise_path = project_root.join("mise.toml"); + let current_content = std::fs::read_to_string(&mise_path).unwrap_or_default(); + let current_tool_keys = parse_tool_keys(¤t_content); + let known_keys: HashSet<&str> = registry.iter().filter_map(install_key).collect(); + + // Step 2: build one group per install key, covering all checks whose files are + // present in the repo or which are already installed. + let mut groups = build_linter_groups( + ®istry, + &present_patterns, + ¤t_tool_keys, + ¤t_content, + &default_categories, + ); + + if groups.is_empty() { + println!("No applicable linters found for this project."); + return Ok(()); + } + + // Step 3: interactive linter table (skipped with --yes). + if !yes && !interactive_select_linters(&mut groups)? { + println!("Aborted."); + return Ok(()); + } + + // Detect obsolete tool keys (e.g. npm:markdownlint-cli → npm:markdownlint-cli2). + // These are removed regardless of the interactive selection — keeping them serves no purpose. + let obsolete = detect_obsolete_keys(¤t_tool_keys); + for (old_key, replacement) in &obsolete { + println!(" removing obsolete linter {old_key} (replaced by {replacement})"); + } + + // Derive changes from final selection state. + let mut final_add: Vec<(String, Option)> = Vec::new(); + let mut final_remove: Vec = Vec::new(); + let mut final_upgrade: Vec<(String, String)> = Vec::new(); + + for group in &groups { + if group.any_selected() { + if !group.installed { + final_add.push((group.key.to_string(), group.selected_components())); + } else { + let target = group.selected_components(); + if target != group.current_components { + // Upgrade: components changed (added, removed, or reordered). + // If the target has no components (e.g. all component-bearing checks + // deselected), treat as a plain-version install via add+remove. + if let Some(comps) = target { + final_upgrade.push((group.key.to_string(), comps)); + } + } + } + } else if group.installed && known_keys.contains(group.key) { + final_remove.push(group.key.to_string()); + } + } + + // Always remove obsolete tool keys (detected before the interactive selection). + for (old_key, _) in &obsolete { + final_remove.push(old_key.to_string()); + } + + let has_slow = has_slow_selected(&groups); + let has_renovate = groups.iter().any(|g| { + g.checks + .iter() + .zip(&g.check_selected) + .any(|(c, &sel)| sel && c.name == "renovate-deps") + }); + let has_markdownlint = groups.iter().any(|g| { + g.checks + .iter() + .zip(&g.check_selected) + .any(|(c, &sel)| sel && c.name == "markdownlint-cli2") + }); + + // Prompt for the flint config dir (skipped if already set in mise.toml or --yes). + let existing_config_dir = get_existing_config_dir(¤t_content); + let config_dir_rel = prompt_config_dir(existing_config_dir.as_deref(), yes)?; + + let tools_changed = + !final_add.is_empty() || !final_remove.is_empty() || !final_upgrade.is_empty(); + if tools_changed { + apply_changes( + &mise_path, + ¤t_content, + &final_add, + &final_remove, + &final_upgrade, + )?; + } + + let v1 = remove_v1_tasks(&mise_path)?; + for key in &v1.removed_tasks { + println!(" removing v1 task {key}"); + } + if v1.removed_renovate_env { + println!(" removing RENOVATE_TRACKED_DEPS_EXCLUDE from [env] (use flint.toml instead)"); + } + + let meta_changed = + apply_env_and_tasks(&mise_path, &config_dir_rel, has_slow, &v1.removed_tasks)?; + + let base_branch = detect_base_branch(project_root); + let config_dir_path = project_root.join(&config_dir_rel); + let toml_generated = generate_flint_toml( + &config_dir_path, + &base_branch, + has_renovate, + v1.renovate_exclude_managers.as_deref(), + )?; + let workflow_generated = generate_lint_workflow(project_root, &base_branch)?; + let markdownlint_generated = if has_markdownlint { + generate_markdownlint_config(project_root)? + } else { + false + }; + + let renovate_patched = find_renovate_config(project_root) + .map(|path| { + let result = patch_renovate_extends(&path); + if let Ok(true) = result { + let rel = path.strip_prefix(project_root).unwrap_or(&path); + println!(" patched {} — added {}", rel.display(), flint_preset()); + } + result + }) + .transpose()? + .unwrap_or(false); + + if !tools_changed + && v1.removed_tasks.is_empty() + && !v1.removed_renovate_env + && !meta_changed + && !toml_generated + && !workflow_generated + && !markdownlint_generated + && !renovate_patched + { + println!("No changes to apply."); + return Ok(()); + } + + maybe_install_hook(project_root, yes)?; + + println!("Done. Run `mise install` to install the new tools."); + Ok(()) +} + +fn find_renovate_config(project_root: &Path) -> Option { + crate::linters::renovate_deps::RENOVATE_CONFIG_PATTERNS + .iter() + .map(|p| project_root.join(p)) + .find(|p| p.exists()) +} + +/// Returns the canonical mise.toml tool key to write when installing this check +/// via `flint init`, or `None` if no mise entry is needed (built-in or +/// unconditionally active checks). +/// +/// Preference order: `mise_install_key` → `mise_tool_name` → `bin_name`. +pub fn install_key(check: &Check) -> Option<&'static str> { + if !check.uses_binary() || check.activate_unconditionally { + return None; + } + Some(check.mise_tool_name.unwrap_or(check.bin_name)) +} + +/// Compute the map of `tool_key → optional_components` for the given category set, +/// filtered to file patterns present in the repo. +#[cfg(test)] +fn compute_desired_tools( + registry: &[Check], + present_patterns: &HashSet, + categories: &HashSet, +) -> DesiredTools { + use detection::files_present; + + // Collect per-key component lists so multiple checks sharing a key are merged. + let mut by_key: HashMap> = HashMap::new(); + for check in registry { + let key = match install_key(check) { + Some(k) => k, + None => continue, + }; + if !files_present(check, present_patterns) { + continue; + } + if categories.contains(&check.category) { + let entry = by_key.entry(key.to_string()).or_default(); + if let Some(comp) = check.mise_install_components { + if !entry.contains(&comp) { + entry.push(comp); + } + } + } + } + by_key + .into_iter() + .map(|(k, comps)| { + let merged = if comps.is_empty() { + None + } else { + Some(comps.join(",")) + }; + (k, merged) + }) + .collect() +} + +// --- Tests --- + +#[cfg(test)] +mod tests { + use super::*; + use detection::entry_components_differ; + use generation::{ + apply_changes, apply_env_and_tasks, generate_flint_toml, generate_lint_workflow, + get_existing_config_dir, has_slow_selected, + }; + + #[test] + fn detect_obsolete_keys_finds_known_stale_key() { + use detection::detect_obsolete_keys; + let mut keys = HashSet::new(); + keys.insert("npm:markdownlint-cli".to_string()); + keys.insert("shellcheck".to_string()); + let found = detect_obsolete_keys(&keys); + assert_eq!(found.len(), 1); + assert_eq!(found[0].0, "npm:markdownlint-cli"); + assert_eq!(found[0].1, "npm:markdownlint-cli2"); + } + + #[test] + fn detect_obsolete_keys_ignores_current_keys() { + use detection::detect_obsolete_keys; + let mut keys = HashSet::new(); + keys.insert("npm:markdownlint-cli2".to_string()); + keys.insert("shellcheck".to_string()); + let found = detect_obsolete_keys(&keys); + assert!(found.is_empty()); + } + + #[test] + fn all_registry_checks_have_install_key_or_none() { + // Every check that uses a binary and isn't unconditional must have a resolvable key. + for check in builtin() { + if check.uses_binary() && !check.activate_unconditionally { + let key = install_key(&check); + assert!( + key.is_some(), + "check '{}' is missing an install key", + check.name + ); + } + } + } + + #[test] + fn entry_components_differ_string_value() { + let content = "[tools]\nrust = \"1.80.0\"\n"; + assert!(entry_components_differ(content, "rust", "clippy,rustfmt")); + } + + #[test] + fn entry_components_differ_inline_table_without_components() { + let content = "[tools]\nrust = { version = \"1.80.0\" }\n"; + assert!(entry_components_differ(content, "rust", "clippy,rustfmt")); + } + + #[test] + fn entry_components_differ_inline_table_wrong_components() { + let content = "[tools]\nrust = { version = \"1.80.0\", components = \"clippy\" }\n"; + assert!(entry_components_differ(content, "rust", "clippy,rustfmt")); + } + + #[test] + fn entry_components_differ_inline_table_correct_components() { + let content = "[tools]\nrust = { version = \"1.80.0\", components = \"clippy,rustfmt\" }\n"; + assert!(!entry_components_differ(content, "rust", "clippy,rustfmt")); + } + + #[test] + fn apply_changes_upgrade_preserves_version() { + let content = "[tools]\nrust = \"1.80.0\"\n"; + let tmp = tempfile::NamedTempFile::new().unwrap(); + apply_changes( + tmp.path(), + content, + &[], + &[], + &[("rust".to_string(), "clippy,rustfmt".to_string())], + ) + .unwrap(); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(result.contains("version = \"1.80.0\""), "version preserved"); + assert!( + result.contains("components = \"clippy,rustfmt\""), + "components added" + ); + } + + #[test] + fn parse_tool_keys_reads_simple_toml() { + let content = r#" +[tools] +shellcheck = "v0.11.0" +"npm:prettier" = "3.8.1" +rust = { version = "1.0", components = "clippy" } +"#; + let keys = parse_tool_keys(content); + assert!(keys.contains("shellcheck")); + assert!(keys.contains("npm:prettier")); + assert!(keys.contains("rust")); + assert!(!keys.contains("nonexistent")); + } + + #[test] + fn compute_desired_tools_lang_profile() { + let registry = builtin(); + let mut present = HashSet::new(); + present.insert("*.sh".to_string()); + present.insert("*.bash".to_string()); + present.insert("*.rs".to_string()); + let categories = profile_to_categories(Profile::Lang); + let tools = compute_desired_tools(®istry, &present, &categories); + // Shell checks are supplementary (Style), not included in the lang profile. + assert!(!tools.contains_key("shellcheck")); + assert!(!tools.contains_key("shfmt")); + // Primary language linters are included. + assert!(tools.contains_key("rust")); + // General tools are not lang-only. + assert!(!tools.contains_key("pipx:codespell")); + } + + #[test] + fn rust_install_entry_has_components() { + let registry = builtin(); + let mut present = HashSet::new(); + present.insert("*.rs".to_string()); + let categories = profile_to_categories(Profile::Lang); + let tools = compute_desired_tools(®istry, &present, &categories); + // Both cargo-clippy and cargo-fmt share the "rust" key; their components are merged. + assert_eq!( + tools.get("rust"), + Some(&Some("clippy,rustfmt".to_string())), + "rust tool entry should carry merged components" + ); + } + + #[test] + fn compute_desired_tools_default_excludes_slow() { + let registry = builtin(); + let present: HashSet = HashSet::new(); + let categories = profile_to_categories(Profile::Default); + let tools = compute_desired_tools(®istry, &present, &categories); + // renovate-deps is slow — should be absent + assert!(!tools.contains_key("npm:renovate")); + // lychee is fast — should be present (empty patterns → always present) + assert!(tools.contains_key("lychee")); + } + + #[test] + fn compute_desired_tools_comprehensive_includes_slow() { + let registry = builtin(); + // Must include renovate config pattern so renovate-deps is considered present. + let mut present: HashSet = HashSet::new(); + present.insert(".github/renovate.json5".to_string()); + let categories = profile_to_categories(Profile::Comprehensive); + let tools = compute_desired_tools(®istry, &present, &categories); + assert!(tools.contains_key("lychee")); + assert!(tools.contains_key("npm:renovate")); + } + + #[test] + fn renovate_deps_absent_without_renovate_config() { + let registry = builtin(); + // No renovate config file in present patterns → renovate-deps should be excluded. + let present: HashSet = HashSet::new(); + let categories = profile_to_categories(Profile::Comprehensive); + let tools = compute_desired_tools(®istry, &present, &categories); + assert!(!tools.contains_key("npm:renovate")); + } + + #[test] + fn has_slow_selected_false_for_default_profile() { + let registry = builtin(); + let present = HashSet::new(); + let categories = profile_to_categories(Profile::Default); + let groups = build_linter_groups(®istry, &present, &HashSet::new(), "", &categories); + assert!(!has_slow_selected(&groups)); + } + + #[test] + fn get_existing_config_dir_reads_env_section() { + let content = "[env]\nFLINT_CONFIG_DIR = \".github/config\"\n"; + assert_eq!( + get_existing_config_dir(content), + Some(".github/config".to_string()) + ); + } + + #[test] + fn get_existing_config_dir_absent() { + let content = "[tools]\nrust = \"latest\"\n"; + assert_eq!(get_existing_config_dir(content), None); + } + + #[test] + fn generate_markdownlint_config_writes_file() { + use generation::generate_markdownlint_config; + let tmp = tempfile::TempDir::new().unwrap(); + let written = generate_markdownlint_config(tmp.path()).unwrap(); + assert!(written); + let content = std::fs::read_to_string(tmp.path().join(".markdownlint.jsonc")).unwrap(); + assert!(content.contains("\"MD013\": false")); + } + + #[test] + fn generate_markdownlint_config_skips_existing() { + use generation::generate_markdownlint_config; + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join(".markdownlint.jsonc"), "existing").unwrap(); + let written = generate_markdownlint_config(tmp.path()).unwrap(); + assert!(!written); + let content = std::fs::read_to_string(tmp.path().join(".markdownlint.jsonc")).unwrap(); + assert_eq!(content, "existing"); + } + + #[test] + fn generate_flint_toml_writes_skeleton() { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = tmp.path().join("config"); + let written = generate_flint_toml(&dir, "main", false, None).unwrap(); + assert!(written); + let content = std::fs::read_to_string(dir.join("flint.toml")).unwrap(); + assert!(content.contains("[settings]")); + assert!(content.contains("# exclude =")); + assert!(content.contains("# exclude_paths =")); + assert!(!content.contains("base_branch")); // "main" is the default, omitted + } + + #[test] + fn generate_flint_toml_non_main_branch() { + let tmp = tempfile::TempDir::new().unwrap(); + let written = generate_flint_toml(tmp.path(), "master", false, None).unwrap(); + assert!(written); + let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); + assert!(content.contains("base_branch = \"master\"")); + } + + #[test] + fn generate_flint_toml_with_renovate_placeholder() { + let tmp = tempfile::TempDir::new().unwrap(); + generate_flint_toml(tmp.path(), "main", true, None).unwrap(); + let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); + assert!(content.contains("[checks.renovate-deps]")); + assert!(content.contains("# exclude_managers =")); + } + + #[test] + fn generate_flint_toml_with_renovate_managers() { + let tmp = tempfile::TempDir::new().unwrap(); + let managers = vec!["github-actions".to_string(), "cargo".to_string()]; + generate_flint_toml(tmp.path(), "main", true, Some(&managers)).unwrap(); + let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); + assert!(content.contains("[checks.renovate-deps]")); + assert!( + content.contains("exclude_managers = [\"github-actions\", \"cargo\"]"), + "managers written uncommented: {content}" + ); + assert!(!content.contains("# exclude_managers")); + } + + #[test] + fn generate_flint_toml_skips_existing() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("flint.toml"), "existing content").unwrap(); + let written = generate_flint_toml(tmp.path(), "main", false, None).unwrap(); + assert!(!written); + let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); + assert_eq!(content, "existing content"); + } + + #[test] + fn generate_lint_workflow_writes_file() { + let tmp = tempfile::TempDir::new().unwrap(); + let written = generate_lint_workflow(tmp.path(), "main").unwrap(); + assert!(written); + let content = + std::fs::read_to_string(tmp.path().join(".github/workflows/lint.yml")).unwrap(); + assert!(content.contains("branches: [main]")); + assert!(content.contains("mise run lint")); + assert!(content.contains("fetch-depth: 0")); + assert!(content.contains("persist-credentials: false")); + assert!(content.contains("mise-action")); + assert!(content.contains("github.token")); + } + + #[test] + fn generate_lint_workflow_non_main_branch() { + let tmp = tempfile::TempDir::new().unwrap(); + generate_lint_workflow(tmp.path(), "master").unwrap(); + let content = + std::fs::read_to_string(tmp.path().join(".github/workflows/lint.yml")).unwrap(); + assert!(content.contains("branches: [master]")); + } + + #[test] + fn generate_lint_workflow_skips_existing() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join(".github/workflows")).unwrap(); + std::fs::write( + tmp.path().join(".github/workflows/lint.yml"), + "existing content", + ) + .unwrap(); + let written = generate_lint_workflow(tmp.path(), "main").unwrap(); + assert!(!written); + let content = + std::fs::read_to_string(tmp.path().join(".github/workflows/lint.yml")).unwrap(); + assert_eq!(content, "existing content"); + } + + #[test] + fn apply_env_and_tasks_adds_sections() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), "[tools]\nrust = \"latest\"\n").unwrap(); + let changed = apply_env_and_tasks(tmp.path(), ".github/config", false, &[]).unwrap(); + assert!(changed); + let content = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(content.contains("FLINT_CONFIG_DIR = \".github/config\"")); + assert!(content.contains("flint run")); + assert!(content.contains("flint run --fix")); + assert!(!content.contains("--fast-only")); // no slow linters + } + + #[test] + fn apply_env_and_tasks_adds_pre_commit_task_when_slow() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), "").unwrap(); + apply_env_and_tasks(tmp.path(), ".", true, &[]).unwrap(); + let content = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(content.contains("--fast-only")); + assert!(content.contains("lint:pre-commit")); + } + + #[test] + fn apply_env_and_tasks_idempotent() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), "").unwrap(); + apply_env_and_tasks(tmp.path(), ".github/config", false, &[]).unwrap(); + let after_first = std::fs::read_to_string(tmp.path()).unwrap(); + let changed = apply_env_and_tasks(tmp.path(), ".github/config", false, &[]).unwrap(); + assert!(!changed); + let after_second = std::fs::read_to_string(tmp.path()).unwrap(); + assert_eq!(after_first, after_second); + } + + #[test] + fn apply_env_and_tasks_replaces_stale_lint_task() { + let content = r#" +[tasks."lint"] +description = "Run all lints" +depends = ["lint:fast", "lint:renovate-deps"] +"#; + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), content).unwrap(); + let removed = vec!["lint:renovate-deps".to_string()]; + apply_env_and_tasks(tmp.path(), ".github/config", false, &removed).unwrap(); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!( + result.contains("run = \"flint run\""), + "stale lint task replaced: {result}" + ); + assert!( + !result.contains("depends"), + "old depends array removed: {result}" + ); + } +} diff --git a/src/init/ui.rs b/src/init/ui.rs new file mode 100644 index 0000000..c0d0753 --- /dev/null +++ b/src/init/ui.rs @@ -0,0 +1,252 @@ +use anyhow::Result; +use std::io::{self, Write}; + +use crossterm::{ + cursor, + event::{self, Event, KeyCode, KeyModifiers}, + execute, + terminal::{self, ClearType}, +}; + +use crate::registry::Category; + +use super::{CategoryItem, LinterGroup}; + +fn run_arrow_selector( + items: &mut [T], + print_fn: fn(&mut dyn Write, &[T], usize) -> Result, + toggle_fn: fn(&mut T), +) -> Result { + let mut cursor = 0usize; + terminal::enable_raw_mode()?; + let result = (|| -> Result { + let mut stdout = io::stdout(); + let mut n_lines = print_fn(&mut stdout, items, cursor)?; + loop { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Up if cursor > 0 => cursor -= 1, + KeyCode::Down if cursor + 1 < items.len() => cursor += 1, + KeyCode::Char(' ') => toggle_fn(&mut items[cursor]), + KeyCode::Enter => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(true); + } + KeyCode::Char('q') | KeyCode::Esc => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(false); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(false); + } + _ => continue, + } + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + n_lines = print_fn(&mut stdout, items, cursor)?; + } + } + })(); + let _ = terminal::disable_raw_mode(); + println!(); + result +} + +// --- Step 1: category selection --- + +pub(super) fn select_categories_arrow(items: &mut [CategoryItem]) -> Result { + run_arrow_selector(items, print_cat_selector, |item| { + item.selected = !item.selected + }) +} + +fn print_cat_selector( + stdout: &mut dyn Write, + items: &[CategoryItem], + cursor: usize, +) -> Result { + let mut lines = 0usize; + write!(stdout, "Select categories:\r\n\r\n")?; + lines += 2; + for (i, item) in items.iter().enumerate() { + let arrow = if i == cursor { ">" } else { " " }; + let sel = if item.selected { "✓" } else { " " }; + write!(stdout, " {} [{}] {}\r\n", arrow, sel, item.label)?; + lines += 1; + } + write!( + stdout, + "\r\n ↑↓ navigate space toggle enter continue q abort\r\n" + )?; + lines += 2; + stdout.flush()?; + Ok(lines) +} + +// --- Step 2: linter table selection --- + +/// Maps a flat row index (across all checks in all groups) to `(group_idx, check_idx)`. +fn flat_to_group_check(groups: &[LinterGroup], flat: usize) -> (usize, usize) { + let mut remaining = flat; + for (gi, group) in groups.iter().enumerate() { + if remaining < group.checks.len() { + return (gi, remaining); + } + remaining -= group.checks.len(); + } + (0, 0) +} + +pub(super) fn interactive_select_linters(groups: &mut Vec) -> Result { + let total_rows = |gs: &[LinterGroup]| gs.iter().map(|g| g.checks.len()).sum::(); + let mut cursor = 0usize; + terminal::enable_raw_mode()?; + let result = (|| -> Result { + let mut stdout = io::stdout(); + let mut n_lines = print_linter_table(&mut stdout, groups, cursor)?; + loop { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Up if cursor > 0 => cursor -= 1, + KeyCode::Down if cursor + 1 < total_rows(groups) => cursor += 1, + KeyCode::Char(' ') => { + let (gi, ci) = flat_to_group_check(groups, cursor); + groups[gi].check_selected[ci] = !groups[gi].check_selected[ci]; + } + KeyCode::Enter => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(true); + } + KeyCode::Char('q') | KeyCode::Esc => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(false); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + return Ok(false); + } + _ => continue, + } + execute!( + stdout, + cursor::MoveUp(n_lines as u16), + terminal::Clear(ClearType::FromCursorDown) + )?; + n_lines = print_linter_table(&mut stdout, groups, cursor)?; + } + } + })(); + let _ = terminal::disable_raw_mode(); + println!(); + result +} + +fn print_linter_table( + stdout: &mut dyn Write, + groups: &[LinterGroup], + cursor: usize, +) -> Result { + let name_w = groups + .iter() + .flat_map(|g| &g.checks) + .map(|c| c.name.len()) + .max() + .unwrap_or(4) + .max(4); + let bin_w = groups + .iter() + .flat_map(|g| &g.checks) + .map(|c| c.bin_name.len()) + .max() + .unwrap_or(6) + .max(6); + + let mut lines = 0usize; + write!( + stdout, + " {:<5} {:" } else { " " }; + let speed = if check.category == Category::Slow { + "slow" + } else { + "fast" + }; + let patterns = check.patterns.join(" "); + write!( + stdout, + " {} {} {: LinterOutput { + let mut all_ok = true; + let mut stderr = Vec::new(); + + for file in files { + let rel = file.strip_prefix(project_root).unwrap_or(file); + let rel_str = rel.to_string_lossy(); + + match check_file(file, &cfg.text, cfg.lines_to_check) { + Ok(true) => {} + Ok(false) => { + all_ok = false; + stderr.extend_from_slice(format!("{rel_str}: missing license header\n").as_bytes()); + } + Err(e) => { + all_ok = false; + stderr.extend_from_slice(format!("{rel_str}: failed to read: {e}\n").as_bytes()); + } + } + } + + LinterOutput { + ok: all_ok, + stdout: vec![], + stderr, + } +} + +/// Returns `true` if `text` appears anywhere within the first `lines_to_check` lines of `path`. +/// `text` may be multi-line; the file head is joined with `\n` before the substring search. +fn check_file(path: &Path, text: &str, lines_to_check: usize) -> std::io::Result { + let f = std::fs::File::open(path)?; + let reader = BufReader::new(f); + let head = reader + .lines() + .take(lines_to_check) + .collect::, _>>()? + .join("\n"); + Ok(head.contains(text)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_file_finds_header_in_first_lines() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Foo.java"); + std::fs::write(&path, "// Copyright 2024 Acme\npublic class Foo {}\n").unwrap(); + assert!(check_file(&path, "Copyright", 5).unwrap()); + } + + #[test] + fn check_file_missing_header() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Foo.java"); + std::fs::write(&path, "public class Foo {}\n").unwrap(); + assert!(!check_file(&path, "Copyright", 5).unwrap()); + } + + #[test] + fn check_file_multiline_text() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Foo.java"); + std::fs::write( + &path, + "/*\n * Copyright Acme\n * SPDX-License-Identifier: Apache-2.0\n */\npublic class Foo {}\n", + ) + .unwrap(); + let header = "/*\n * Copyright Acme\n * SPDX-License-Identifier: Apache-2.0\n */"; + assert!(check_file(&path, header, 5).unwrap()); + // Partial match still works (single-line substring within the joined head) + assert!(check_file(&path, "SPDX-License-Identifier: Apache-2.0", 5).unwrap()); + // Text that spans more lines than the limit is not found + assert!(!check_file(&path, header, 2).unwrap()); + } + + #[test] + fn check_file_header_beyond_line_limit() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Foo.java"); + std::fs::write( + &path, + "line1\nline2\nline3\n// Copyright 2024 Acme\npublic class Foo {}\n", + ) + .unwrap(); + // Header is on line 4; with limit=3 it should not be found. + assert!(!check_file(&path, "Copyright", 3).unwrap()); + // With limit=5 it should be found. + assert!(check_file(&path, "Copyright", 5).unwrap()); + } +} diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs new file mode 100644 index 0000000..f8afd4a --- /dev/null +++ b/src/linters/lychee.rs @@ -0,0 +1,477 @@ +use std::path::Path; +use std::process::Stdio; +use tokio::process::Command; + +use crate::config::LycheeConfig; +use crate::files::FileList; +use crate::linters::LinterOutput; + +pub async fn run( + cfg: &LycheeConfig, + file_list: &FileList, + project_root: &Path, + config_dir: &Path, +) -> LinterOutput { + let lychee_cfg_raw = cfg.config.as_deref().unwrap_or("lychee.toml"); + let lychee_cfg = if Path::new(lychee_cfg_raw).is_relative() { + config_dir + .join(lychee_cfg_raw) + .to_string_lossy() + .into_owned() + } else { + lychee_cfg_raw.to_string() + }; + + let remap_args = build_remap_args(project_root).await; + + // Full mode: no merge base (shallow clone or --full flag) + if file_list.merge_base.is_none() { + return run_lychee_cmd( + "Checking all links in all files", + &lychee_cfg, + &remap_args, + &["."], + false, + project_root, + ) + .await; + } + + // Check if lychee config is in the changed file list + let config_changed = file_list + .files + .iter() + .any(|f| f.as_path() == Path::new(&lychee_cfg)); + + if config_changed { + let mut out = run_lychee_cmd( + "Checking all links in all files", + &lychee_cfg, + &remap_args, + &["."], + false, + project_root, + ) + .await; + let mut stderr = b"Config changes detected, falling back to full check.\n".to_vec(); + stderr.extend_from_slice(&out.stderr); + out.stderr = stderr; + return out; + } + + // Diff mode: filter changed files to link-checkable extensions + let checkable: Vec = file_list + .files + .iter() + .filter(|f| is_link_checkable(f)) + .map(|f| { + f.strip_prefix(project_root) + .unwrap_or(f) + .to_string_lossy() + .into_owned() + }) + .collect(); + + let mut all_ok = true; + let mut combined_stdout = Vec::new(); + let mut combined_stderr = Vec::new(); + + if !checkable.is_empty() { + let file_refs: Vec<&str> = checkable.iter().map(String::as_str).collect(); + let out = run_lychee_cmd( + "Checking all links in modified files", + &lychee_cfg, + &remap_args, + &file_refs, + false, + project_root, + ) + .await; + all_ok &= out.ok; + combined_stdout.extend_from_slice(&out.stdout); + combined_stderr.extend_from_slice(&out.stderr); + } else { + combined_stdout.extend_from_slice(b"No modified files to check for all links.\n"); + } + + if cfg.check_all_local { + let out = run_lychee_cmd( + "Checking local links in all files", + &lychee_cfg, + &remap_args, + &["."], + true, + project_root, + ) + .await; + all_ok &= out.ok; + combined_stdout.extend_from_slice(&out.stdout); + combined_stderr.extend_from_slice(&out.stderr); + } + + LinterOutput { + ok: all_ok, + stdout: combined_stdout, + stderr: combined_stderr, + } +} + +async fn run_lychee_cmd( + description: &str, + lychee_cfg: &str, + remap_args: &[String], + files: &[&str], + local_only: bool, + project_root: &Path, +) -> LinterOutput { + let mut argv: Vec = vec![ + "lychee".to_string(), + "--config".to_string(), + lychee_cfg.to_string(), + ]; + + if local_only { + argv.push("--scheme".to_string()); + argv.push("file".to_string()); + argv.push("--include-fragments".to_string()); + } + + argv.extend_from_slice(remap_args); + argv.push("--".to_string()); + argv.extend(files.iter().map(|s| s.to_string())); + + let mut stdout = format!("==> {description}\n").into_bytes(); + + let result = super::spawn_command(&argv) + .current_dir(project_root) + .stdin(Stdio::null()) + .output() + .await; + + match result { + Ok(out) => { + stdout.extend_from_slice(&out.stdout); + LinterOutput { + ok: out.status.success(), + stdout, + stderr: out.stderr, + } + } + Err(e) => LinterOutput { + ok: false, + stdout, + stderr: format!("flint: links: failed to spawn lychee: {e}\n").into_bytes(), + }, + } +} + +/// Returns the GitHub server URL from `GITHUB_SERVER_URL`, defaulting to `https://github.com`. +fn github_server_url() -> String { + std::env::var("GITHUB_SERVER_URL") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "https://github.com".to_string()) +} + +/// Returns the base URL for raw file content. +/// GitHub.com uses a separate subdomain; GitHub Enterprise serves raw content at `{server}/raw`. +fn raw_content_base(server_url: &str) -> String { + if server_url == "https://github.com" { + "https://raw.githubusercontent.com".to_string() + } else { + format!("{server_url}/raw") + } +} + +async fn build_remap_args(project_root: &Path) -> Vec { + if std::env::var("LYCHEE_SKIP_GITHUB_REMAPS").as_deref() == Ok("true") { + return vec![]; + } + let server = github_server_url(); + let raw_base = raw_content_base(&server); + let mut args = build_global_github_args(&server, &raw_base); + args.extend(build_branch_remap_args(project_root, &server, &raw_base).await); + args +} + +fn build_global_github_args(server: &str, raw_base: &str) -> Vec { + let mut args = Vec::new(); + push_remap( + &mut args, + format!(r"^{server}/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#L[0-9]+.*$ {raw_base}/$1/$2/$3"), + ); + push_remap( + &mut args, + format!(r"^{server}/([^/]+/[^/]+)/blob/([^/]+)/(.*?)#:~:text=.*$ {raw_base}/$1/$2/$3"), + ); + push_remap( + &mut args, + format!(r"^{server}/([^/]+/[^/]+)/blob/([^/]+)/(.*)$ {raw_base}/$1/$2/$3"), + ); + push_remap( + &mut args, + format!( + r"^{server}/([^/]+/[^/]+)/(issues|pull)/([0-9]+)#issuecomment-.*$ {server}/$1/$2/$3" + ), + ); + args +} + +async fn build_branch_remap_args(project_root: &Path, server: &str, raw_base: &str) -> Vec { + let Some(repo) = resolve_repo(project_root, server).await else { + return vec![]; + }; + let base_ref = resolve_base_ref(project_root).await; + let Some(head_ref) = resolve_head_ref(project_root).await else { + return vec![]; + }; + + if head_ref == base_ref { + return vec![]; + } + + let head_repo = std::env::var("PR_HEAD_REPO").unwrap_or_else(|_| repo.clone()); + let base_url = format!("{server}/{repo}"); + let mut args = Vec::new(); + + if head_repo == repo { + let pwd = project_root.to_string_lossy(); + push_remap( + &mut args, + format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ file://{pwd}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/blob/{base_ref}/(.*?)#:~:text=.*$ file://{pwd}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/blob/{base_ref}/(.*)$ file://{pwd}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/tree/{base_ref}/(.*?)#L[0-9]+.*$ file://{pwd}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/tree/{base_ref}/(.*)$ file://{pwd}/$1"), + ); + } else { + let raw_head = format!("{raw_base}/{head_repo}/{head_ref}"); + let head_url = format!("{server}/{head_repo}"); + push_remap( + &mut args, + format!("^{base_url}/blob/{base_ref}/(.*?)#L[0-9]+.*$ {raw_head}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/blob/{base_ref}/(.*?)#:~:text=.*$ {raw_head}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/blob/{base_ref}/(.*)$ {raw_head}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/tree/{base_ref}/(.*?)#L[0-9]+.*$ {head_url}/tree/{head_ref}/$1"), + ); + push_remap( + &mut args, + format!("^{base_url}/tree/{base_ref}/(.*)$ {head_url}/tree/{head_ref}/$1"), + ); + } + + args +} + +fn push_remap(args: &mut Vec, pattern: impl Into) { + args.push("--remap".to_string()); + args.push(pattern.into()); +} + +/// Runs a git command and returns its trimmed stdout, or `None` if it fails or is empty. +async fn run_git_output(project_root: &Path, args: &[&str]) -> Option { + let out = Command::new("git") + .args(args) + .current_dir(project_root) + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } +} + +async fn resolve_repo(project_root: &Path, server: &str) -> Option { + if let Ok(repo) = std::env::var("GITHUB_REPOSITORY") + && !repo.is_empty() + { + return Some(repo); + } + run_git_output(project_root, &["config", "--get", "remote.origin.url"]) + .await + .and_then(|url| parse_github_repo(&url, server)) +} + +async fn resolve_base_ref(project_root: &Path) -> String { + if let Ok(base) = std::env::var("GITHUB_BASE_REF") + && !base.is_empty() + { + return base; + } + run_git_output(project_root, &["symbolic-ref", "refs/remotes/origin/HEAD"]) + .await + .as_deref() + .and_then(|s| s.rsplit('/').next()) + .map(String::from) + .unwrap_or_else(|| "main".to_string()) +} + +async fn resolve_head_ref(project_root: &Path) -> Option { + if let Ok(head) = std::env::var("GITHUB_HEAD_REF") + && !head.is_empty() + { + return Some(head); + } + run_git_output(project_root, &["rev-parse", "--abbrev-ref", "HEAD"]).await +} + +fn parse_github_repo(url: &str, server: &str) -> Option { + // HTTPS: https:///owner/repo.git or https:///owner/repo + let https_prefix = format!("{server}/"); + if let Some(rest) = url.strip_prefix(https_prefix.as_str()) { + let repo = rest.trim_end_matches(".git"); + if !repo.is_empty() { + return Some(repo.to_string()); + } + } + // SSH: git@:owner/repo.git or git@:owner/repo + let hostname = server.strip_prefix("https://").unwrap_or(server); + let ssh_prefix = format!("git@{hostname}:"); + if let Some(rest) = url.strip_prefix(ssh_prefix.as_str()) { + let repo = rest.trim_end_matches(".git"); + if !repo.is_empty() { + return Some(repo.to_string()); + } + } + None +} + +fn is_link_checkable(path: &Path) -> bool { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + matches!( + ext.as_str(), + "md" | "mkd" + | "mdx" + | "mdown" + | "mdwn" + | "mkdn" + | "mkdown" + | "markdown" + | "html" + | "htm" + | "txt" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_github_repo_https() { + assert_eq!( + parse_github_repo("https://github.com/owner/repo", "https://github.com"), + Some("owner/repo".to_string()) + ); + } + + #[test] + fn parse_github_repo_https_dotgit() { + assert_eq!( + parse_github_repo("https://github.com/owner/repo.git", "https://github.com"), + Some("owner/repo".to_string()) + ); + } + + #[test] + fn parse_github_repo_ssh() { + assert_eq!( + parse_github_repo("git@github.com:owner/repo", "https://github.com"), + Some("owner/repo".to_string()) + ); + } + + #[test] + fn parse_github_repo_ssh_dotgit() { + assert_eq!( + parse_github_repo("git@github.com:owner/repo.git", "https://github.com"), + Some("owner/repo".to_string()) + ); + } + + #[test] + fn parse_github_repo_non_github() { + assert_eq!( + parse_github_repo("https://gitlab.com/owner/repo", "https://github.com"), + None + ); + } + + #[test] + fn parse_github_repo_empty_path() { + assert_eq!( + parse_github_repo("https://github.com/", "https://github.com"), + None + ); + } + + #[test] + fn parse_github_repo_ghe_https() { + assert_eq!( + parse_github_repo( + "https://github.mycompany.com/owner/repo", + "https://github.mycompany.com" + ), + Some("owner/repo".to_string()) + ); + } + + #[test] + fn parse_github_repo_ghe_ssh() { + assert_eq!( + parse_github_repo( + "git@github.mycompany.com:owner/repo.git", + "https://github.mycompany.com" + ), + Some("owner/repo".to_string()) + ); + } + + #[test] + fn is_link_checkable_md() { + assert!(is_link_checkable(Path::new("README.md"))); + assert!(is_link_checkable(Path::new("docs/page.markdown"))); + assert!(is_link_checkable(Path::new("file.html"))); + assert!(is_link_checkable(Path::new("notes.txt"))); + } + + #[test] + fn is_link_checkable_case_insensitive() { + assert!(is_link_checkable(Path::new("README.MD"))); + assert!(is_link_checkable(Path::new("page.HTML"))); + } + + #[test] + fn is_link_checkable_non_checkable() { + assert!(!is_link_checkable(Path::new("main.rs"))); + assert!(!is_link_checkable(Path::new("config.toml"))); + assert!(!is_link_checkable(Path::new("script.sh"))); + assert!(!is_link_checkable(Path::new("Makefile"))); + } +} diff --git a/src/linters/mod.rs b/src/linters/mod.rs new file mode 100644 index 0000000..af3330e --- /dev/null +++ b/src/linters/mod.rs @@ -0,0 +1,102 @@ +pub mod license_header; +pub mod lychee; +pub mod renovate_deps; + +/// Build a [`tokio::process::Command`] for the given argv. +/// +/// On Windows, mise shims are `.cmd` files that cannot be spawned directly +/// via `CreateProcessW`. However, some tools (e.g. ktlint) are native PE +/// binaries without a `.exe` extension that also cannot run via cmd.exe +/// (the shim fails). We check for a PE header (MZ magic) to distinguish: +/// - PE binary without extension → execute directly by full path +/// - Everything else → route through `cmd.exe /C` to handle `.cmd` shims +pub fn spawn_command(argv: &[String]) -> tokio::process::Command { + #[cfg(windows)] + { + match find_executable_in_path(&argv[0]) { + Some(WinBinary::Pe(path)) => { + let mut cmd = tokio::process::Command::new(path); + cmd.args(&argv[1..]); + return cmd; + } + Some(WinBinary::Jar(path)) => { + let mut cmd = tokio::process::Command::new("java"); + cmd.arg("-jar").arg(path).args(&argv[1..]); + return cmd; + } + None => {} + } + let mut cmd = tokio::process::Command::new("cmd.exe"); + cmd.arg("/C").args(argv); + cmd + } + #[cfg(not(windows))] + { + let mut cmd = tokio::process::Command::new(&argv[0]); + cmd.args(&argv[1..]); + cmd + } +} + +/// What kind of executable was found in PATH on Windows. +#[cfg(windows)] +enum WinBinary { + /// Native PE binary (MZ magic) — execute directly. + Pe(std::path::PathBuf), + /// Self-executing JAR (starts with `#!` and is large) — run via `java -jar`. + Jar(std::path::PathBuf), +} + +/// On Windows, look for `binary` (exact name, no extension) in each PATH +/// directory and classify it: +/// - MZ magic → native PE, run directly +/// - `#!` magic + large file (>1 MB) → self-executing JAR (e.g. ktlint), run via `java -jar` +#[cfg(windows)] +fn find_executable_in_path(binary: &str) -> Option { + use std::io::Read; + let path_var = std::env::var("PATH").ok()?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(binary); + if !candidate.is_file() { + continue; + } + let mut buf = [0u8; 2]; + let read = std::fs::File::open(&candidate) + .and_then(|mut f| f.read(&mut buf).map(|n| n)) + .unwrap_or(0); + if read < 2 { + continue; + } + if buf == [b'M', b'Z'] { + return Some(WinBinary::Pe(candidate)); + } + if buf == [b'#', b'!'] { + // Self-executing JAR: shell script header prepended to a JAR. + // A real script would be tiny; a self-executing JAR is many MB. + if std::fs::metadata(&candidate) + .map(|m| m.len() > 1_000_000) + .unwrap_or(false) + { + return Some(WinBinary::Jar(candidate)); + } + } + } + None +} + +/// Output from a single linter run. +pub struct LinterOutput { + pub ok: bool, + pub stdout: Vec, + pub stderr: Vec, +} + +impl LinterOutput { + pub fn err(stderr: impl Into>) -> Self { + Self { + ok: false, + stdout: vec![], + stderr: stderr.into(), + } + } +} diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs new file mode 100644 index 0000000..b60e704 --- /dev/null +++ b/src/linters/renovate_deps.rs @@ -0,0 +1,463 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use crate::config::RenovateDepsConfig; +use crate::linters::LinterOutput; + +const COMMITTED_FILE: &str = "renovate-tracked-deps.json"; +pub(crate) const COMMITTED_PATHS: &[&str] = &[COMMITTED_FILE, ".github/renovate-tracked-deps.json"]; +pub(crate) const RENOVATE_CONFIG_PATTERNS: &[&str] = &[ + "renovate.json", + "renovate.json5", + ".github/renovate.json", + ".github/renovate.json5", + ".renovaterc", + ".renovaterc.json", + ".renovaterc.json5", +]; +const PACKAGE_FILES_MSG: &str = "Extracted dependencies"; +const SKIP_REASONS: &[&str] = &["contains-variable", "invalid-value", "invalid-version"]; + +/// `{file_path: {manager: [dep_name, ...]}}` — all collections sorted. +type DepMap = BTreeMap>>; + +pub async fn run(cfg: &RenovateDepsConfig, fix: bool, project_root: &Path) -> LinterOutput { + match run_inner(cfg, fix, project_root).await { + Ok(out) => out, + Err(e) => LinterOutput::err(format!("flint: renovate-deps: {e}\n")), + } +} + +async fn run_inner( + cfg: &RenovateDepsConfig, + fix: bool, + project_root: &Path, +) -> anyhow::Result { + let config_path = resolve_renovate_config_path(project_root)?; + let committed_path = committed_path_for_config(&config_path); + let committed_display = display_path(project_root, &committed_path); + + // Renovate occasionally produces empty packageFiles on the first run (transient + // network or registry issue). Retry up to 3 times with a short delay. + let mut generated = DepMap::default(); + for attempt in 1..=3u32 { + let log_bytes = run_renovate(project_root, &config_path).await?; + generated = extract_deps(&log_bytes, &cfg.exclude_managers)?; + if !generated.is_empty() || attempt == 3 { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + + if !committed_path.exists() { + if fix { + write_snapshot(&committed_path, &generated)?; + return Ok(LinterOutput { + ok: true, + stdout: format!("{COMMITTED_FILE} has been created.\n").into_bytes(), + stderr: vec![], + }); + } + return Ok(LinterOutput::err(format!( + "ERROR: {committed_display} does not exist.\nRun `flint run --fix renovate-deps` to create it.\n" + ))); + } + + let committed: DepMap = serde_json::from_str(&std::fs::read_to_string(&committed_path)?)?; + + if committed == generated { + return Ok(LinterOutput { + ok: true, + stdout: format!("{COMMITTED_FILE} is up to date.\n").into_bytes(), + stderr: vec![], + }); + } + + let diff = unified_diff(&committed, &generated, &committed_display); + + if fix { + write_snapshot(&committed_path, &generated)?; + let mut stdout = diff.into_bytes(); + stdout.extend_from_slice(format!("{COMMITTED_FILE} has been updated.\n").as_bytes()); + return Ok(LinterOutput { + ok: true, + stdout, + stderr: vec![], + }); + } + + Ok(LinterOutput { + ok: false, + stdout: diff.into_bytes(), + stderr: format!( + "ERROR: {COMMITTED_FILE} is out of date.\nRun `flint run --fix renovate-deps` to update.\n" + ) + .into_bytes(), + }) +} + +/// Runs `renovate --platform=local` and returns the combined stdout+stderr log bytes. +async fn run_renovate(project_root: &Path, config_path: &Path) -> anyhow::Result> { + // Forward env, setting Renovate-specific vars. + let mut env: Vec<(String, String)> = std::env::vars().collect(); + // Override logging to get parseable JSON output. + env.retain(|(k, _)| k != "LOG_LEVEL" && k != "LOG_FORMAT" && k != "RENOVATE_CONFIG_FILE"); + env.push(("LOG_LEVEL".into(), "debug".into())); + env.push(("LOG_FORMAT".into(), "json".into())); + env.push(( + "RENOVATE_CONFIG_FILE".into(), + config_path.to_string_lossy().into_owned(), + )); + // Renovate uses GITHUB_COM_TOKEN for github.com API calls; fall back to GITHUB_TOKEN. + let has_com_token = std::env::var("GITHUB_COM_TOKEN") + .map(|v| !v.is_empty()) + .unwrap_or(false); + if !has_com_token + && let Ok(token) = std::env::var("GITHUB_TOKEN") + && !token.is_empty() + { + env.push(("GITHUB_COM_TOKEN".into(), token)); + } + + let out = super::spawn_command(&[ + "renovate".to_string(), + "--platform=local".to_string(), + "--require-config=ignored".to_string(), + "--dry-run=extract".to_string(), + ]) + .current_dir(project_root) + .envs(env) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + + // Combine stdout+stderr: Renovate writes JSON log lines to stdout, but + // some startup messages may appear on stderr. + let mut combined = out.stdout; + combined.extend_from_slice(&out.stderr); + + if !out.status.success() { + let snippet = String::from_utf8_lossy(&combined); + anyhow::bail!( + "renovate exited with status {}: {}", + out.status.code().unwrap_or(-1), + snippet.lines().take(20).collect::>().join("\n") + ); + } + + Ok(combined) +} + +fn resolve_renovate_config_path(project_root: &Path) -> anyhow::Result { + RENOVATE_CONFIG_PATTERNS + .iter() + .map(|path| project_root.join(path)) + .find(|path| path.exists()) + .ok_or_else(|| { + anyhow::anyhow!( + "no supported Renovate config file found; tried: {}", + RENOVATE_CONFIG_PATTERNS.join(", ") + ) + }) +} + +fn committed_path_for_config(config_path: &Path) -> PathBuf { + config_path + .parent() + .unwrap_or_else(|| Path::new("")) + .join(COMMITTED_FILE) +} + +fn display_path(project_root: &Path, path: &Path) -> String { + path.strip_prefix(project_root) + .unwrap_or(path) + .to_string_lossy() + .into_owned() +} + +/// Parses Renovate's NDJSON log and returns the dep map. +fn extract_deps(log_bytes: &[u8], exclude_managers: &[String]) -> anyhow::Result { + let log = std::str::from_utf8(log_bytes)?; + + let exclude: HashSet<&str> = exclude_managers.iter().map(String::as_str).collect(); + + // Find the last "packageFiles with updates" log entry — Renovate emits it + // once per run with the full resolved config. + let mut config_obj: Option = None; + for line in log.lines() { + let Ok(entry) = serde_json::from_str::(line) else { + continue; + }; + if entry.get("msg").and_then(|v| v.as_str()) == Some(PACKAGE_FILES_MSG) { + config_obj = entry.get("packageFiles").cloned(); + } + } + + let config = config_obj + .ok_or_else(|| anyhow::anyhow!("'{PACKAGE_FILES_MSG}' not found in Renovate log"))?; + + let mut deps_by_file: BTreeMap>> = BTreeMap::new(); + + if let Some(obj) = config.as_object() { + for (manager, manager_files) in obj { + if exclude.contains(manager.as_str()) { + continue; + } + let Some(files) = manager_files.as_array() else { + continue; + }; + for pkg_file in files { + let file_path = pkg_file + .get("packageFile") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let Some(deps) = pkg_file.get("deps").and_then(|v| v.as_array()) else { + continue; + }; + for dep in deps { + let skip_reason = dep.get("skipReason").and_then(|v| v.as_str()); + if SKIP_REASONS.contains(&skip_reason.unwrap_or("")) { + continue; + } + let Some(dep_name) = dep.get("depName").and_then(|v| v.as_str()) else { + continue; + }; + deps_by_file + .entry(file_path.clone()) + .or_default() + .entry(manager.clone()) + .or_default() + .insert(dep_name.to_string()); + } + } + } + } + + // BTreeMap + BTreeSet already sorted; convert sets to vecs. + Ok(deps_by_file + .into_iter() + .map(|(file, managers)| { + let managers = managers + .into_iter() + .map(|(m, deps)| (m, deps.into_iter().collect::>())) + .collect(); + (file, managers) + }) + .collect()) +} + +fn write_snapshot(path: &Path, deps: &DepMap) -> anyhow::Result<()> { + let json = serde_json::to_string_pretty(deps)?; + std::fs::write(path, json + "\n")?; + Ok(()) +} + +fn unified_diff(old: &DepMap, new: &DepMap, committed_display: &str) -> String { + let old_text = serde_json::to_string_pretty(old).unwrap_or_default() + "\n"; + let new_text = serde_json::to_string_pretty(new).unwrap_or_default() + "\n"; + + let diff = similar::TextDiff::from_lines(&old_text, &new_text); + diff.unified_diff() + .header(committed_display, "generated") + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn log(config_json: &str) -> Vec { + format!(r#"{{"msg":"Extracted dependencies","packageFiles":{config_json}}}"#).into_bytes() + } + + fn dep_map(entries: &[(&str, &[(&str, &[&str])])]) -> DepMap { + entries + .iter() + .map(|(file, managers)| { + let m = managers + .iter() + .map(|(mgr, deps)| { + ( + mgr.to_string(), + deps.iter().map(|d| d.to_string()).collect(), + ) + }) + .collect(); + (file.to_string(), m) + }) + .collect() + } + + #[test] + fn extracts_deps_basic() { + let log = log( + r#"{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}"#, + ); + let result = extract_deps(&log, &[]).unwrap(); + assert_eq!( + result, + dep_map(&[("package.json", &[("npm", &["express", "lodash"])])]) + ); + } + + #[test] + fn deps_are_sorted() { + let log = log( + r#"{"npm":[{"packageFile":"package.json","deps":[{"depName":"zebra"},{"depName":"alpha"},{"depName":"moose"}]}]}"#, + ); + let result = extract_deps(&log, &[]).unwrap(); + assert_eq!( + result["package.json"]["npm"], + vec!["alpha", "moose", "zebra"] + ); + } + + #[test] + fn filters_skip_reasons() { + let log = log( + r#"{"npm":[{"packageFile":"package.json","deps":[{"depName":"keep"},{"depName":"bad1","skipReason":"contains-variable"},{"depName":"bad2","skipReason":"invalid-value"},{"depName":"bad3","skipReason":"invalid-version"}]}]}"#, + ); + let result = extract_deps(&log, &[]).unwrap(); + assert_eq!(result["package.json"]["npm"], vec!["keep"]); + } + + #[test] + fn other_skip_reasons_are_kept() { + let log = log( + r#"{"npm":[{"packageFile":"package.json","deps":[{"depName":"pinned","skipReason":"pinned-major-version"}]}]}"#, + ); + let result = extract_deps(&log, &[]).unwrap(); + assert_eq!(result["package.json"]["npm"], vec!["pinned"]); + } + + #[test] + fn excludes_managers() { + let log = log( + r#"{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"}]}],"cargo":[{"packageFile":"Cargo.toml","deps":[{"depName":"tokio"}]}]}"#, + ); + let result = extract_deps(&log, &["npm".to_string()]).unwrap(); + assert!(!result.contains_key("package.json")); + assert_eq!(result["Cargo.toml"]["cargo"], vec!["tokio"]); + } + + #[test] + fn skips_deps_without_dep_name() { + let log = log( + r#"{"npm":[{"packageFile":"package.json","deps":[{"version":"1.0.0"},{"depName":"valid"}]}]}"#, + ); + let result = extract_deps(&log, &[]).unwrap(); + assert_eq!(result["package.json"]["npm"], vec!["valid"]); + } + + #[test] + fn last_package_files_message_wins() { + let bytes = format!( + "{}\n{}\n", + r#"{"msg":"Extracted dependencies","packageFiles":{"npm":[{"packageFile":"a.json","deps":[{"depName":"old"}]}]}}"#, + r#"{"msg":"Extracted dependencies","packageFiles":{"npm":[{"packageFile":"b.json","deps":[{"depName":"new"}]}]}}"#, + ) + .into_bytes(); + let result = extract_deps(&bytes, &[]).unwrap(); + assert!(!result.contains_key("a.json"), "should use last entry"); + assert!(result.contains_key("b.json")); + } + + #[test] + fn non_json_lines_are_skipped() { + let bytes = + b"not json\n{\"msg\":\"Extracted dependencies\",\"packageFiles\":{\"npm\":[{\"packageFile\":\"p.json\",\"deps\":[{\"depName\":\"x\"}]}]}}\nmore garbage\n"; + let result = extract_deps(bytes, &[]).unwrap(); + assert!(result.contains_key("p.json")); + } + + #[test] + fn missing_message_returns_error() { + let bytes = b"{\"msg\":\"something else\"}\n"; + let err = extract_deps(bytes, &[]).unwrap_err(); + assert!(err.to_string().contains(PACKAGE_FILES_MSG)); + } + + #[test] + fn write_and_read_snapshot_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.json"); + let deps = dep_map(&[ + ("Cargo.toml", &[("cargo", &["serde", "tokio"])]), + ("package.json", &[("npm", &["express", "lodash"])]), + ]); + write_snapshot(&path, &deps).unwrap(); + let read_back: DepMap = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(deps, read_back); + } + + #[test] + fn write_snapshot_ends_with_newline() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.json"); + write_snapshot(&path, &dep_map(&[])).unwrap(); + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.ends_with('\n')); + } + + #[test] + fn unified_diff_contains_added_and_removed_lines() { + let old = dep_map(&[("a.json", &[("npm", &["old-dep"])])]); + let new = dep_map(&[("a.json", &[("npm", &["new-dep"])])]); + let diff = unified_diff(&old, &new, ".github/renovate-tracked-deps.json"); + assert!(diff.contains("-"), "should have removals"); + assert!(diff.contains("+"), "should have additions"); + assert!(diff.contains("old-dep")); + assert!(diff.contains("new-dep")); + } + + #[test] + fn unified_diff_header_uses_display_path() { + let old = dep_map(&[("a.json", &[("npm", &["x"])])]); + let new = dep_map(&[("a.json", &[("npm", &["y"])])]); + let diff = unified_diff(&old, &new, "renovate-tracked-deps.json"); + assert!(diff.contains("renovate-tracked-deps.json")); + } + + #[test] + fn resolves_supported_renovate_config_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".renovaterc.json"); + std::fs::write(&config_path, "{}\n").unwrap(); + + let resolved = resolve_renovate_config_path(dir.path()).unwrap(); + + assert_eq!(resolved, config_path); + } + + #[test] + fn missing_supported_renovate_config_file_returns_error() { + let dir = tempfile::tempdir().unwrap(); + + let err = resolve_renovate_config_path(dir.path()).unwrap_err(); + let msg = err.to_string(); + + assert!(msg.contains("no supported Renovate config file found")); + assert!( + RENOVATE_CONFIG_PATTERNS + .iter() + .all(|path| msg.contains(path)) + ); + } + + #[test] + fn committed_path_uses_same_dir_as_found_config() { + assert_eq!( + committed_path_for_config(Path::new("renovate.json5")), + PathBuf::from("renovate-tracked-deps.json") + ); + assert_eq!( + committed_path_for_config(Path::new(".github/renovate.json5")), + PathBuf::from(".github/renovate-tracked-deps.json") + ); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..340710b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,493 @@ +mod config; +mod files; +mod hook; +mod init; +mod linters; +mod registry; +mod runner; + +use anyhow::Result; +use clap::{Args, Parser, Subcommand}; +use registry::{CheckKind, Scope}; +use runner::{CheckResult, RunOptions}; +use std::collections::HashMap; + +#[derive(Parser, Debug)] +#[command(name = "flint", about = "flint — fast lint")] +#[command(subcommand_required = true, arg_required_else_help = true)] +struct Cli { + #[command(subcommand)] + command: SubCommand, +} + +#[derive(Subcommand, Debug)] +enum SubCommand { + /// Lint the code. + Run(RunArgs), + /// List available linters and their status. + Linters(LintersArgs), + /// Set up linters in mise.toml for this project. + Init(InitArgs), + /// Manage git hooks. + Hook(HookArgs), + /// Display the flint version. + Version, +} + +#[derive(Args, Debug)] +struct HookArgs { + #[command(subcommand)] + command: HookCommand, +} + +#[derive(Subcommand, Debug)] +enum HookCommand { + /// Install a pre-commit hook that runs `flint run --fix --fast-only`. + Install, +} + +#[derive(Args, Debug)] +struct LintersArgs { + /// Output as JSON instead of the human-readable table. + #[arg(long)] + json: bool, +} + +#[derive(Args, Debug)] +struct InitArgs { + /// Profile to configure: lang, default, or comprehensive. + #[arg(long, value_enum)] + profile: Option, + + /// Apply changes without prompting for confirmation. + #[arg(long, short = 'y')] + yes: bool, +} + +#[derive(Args, Debug)] +struct RunArgs { + /// Fix what's fixable, report what still needs review. + /// Exits 1 if anything was fixed (uncommitted) or needs review; 0 if already clean. + #[arg(long, env = "FLINT_FIX")] + fix: bool, + + /// Lint all files instead of only changed files. + #[arg(long, env = "FLINT_FULL")] + full: bool, + + /// Run only fast linters. Overridden by explicitly named linters. + #[arg(long, env = "FLINT_FAST_ONLY")] + fast_only: bool, + + /// Show all linter output, not just failures. + #[arg(long, env = "FLINT_VERBOSE")] + verbose: bool, + + /// Compact summary output — no per-check noise (human) or read-only AI review. + #[arg(long, env = "FLINT_SHORT")] + short: bool, + + /// Show only new issues created after git revision REV + /// (default: merge base with base branch). + #[arg(long, value_name = "REV", env = "FLINT_NEW_FROM_REV")] + new_from_rev: Option, + + /// Compare changed files to this ref (default: HEAD). + #[arg(long, value_name = "REF", env = "FLINT_TO_REF")] + to_ref: Option, + + /// Show how long each linter took to run. + #[arg(long, env = "FLINT_TIME")] + time: bool, + + /// Linters to run (default: all discovered). Explicit linters override --fast-only. + linters: Vec, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + let project_root = std::env::var("MISE_PROJECT_ROOT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::env::current_dir().expect("cannot determine working directory")); + // Canonicalize to resolve symlinks (e.g. /private/... on macOS). + // dunce::canonicalize strips the \\?\ verbatim prefix on Windows that + // git and other tools don't handle. + let project_root = dunce::canonicalize(&project_root).unwrap_or(project_root); + + let config_dir = std::env::var("FLINT_CONFIG_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| project_root.clone()); + + std::env::set_current_dir(&project_root)?; + + let registry = registry::builtin(); + + match cli.command { + SubCommand::Version => { + println!("flint {}", env!("CARGO_PKG_VERSION")); + } + SubCommand::Linters(args) => { + let cfg = config::load(&config_dir).unwrap_or_default(); + let mise_tools = registry::read_mise_tools(&project_root); + if args.json { + print_linters_json(®istry); + } else { + print_linters(®istry, &mise_tools, &cfg); + } + } + SubCommand::Init(args) => { + init::run(&project_root, args.profile, args.yes)?; + } + SubCommand::Hook(args) => match args.command { + HookCommand::Install => hook::install(&project_root)?, + }, + SubCommand::Run(args) => { + run(args, &project_root, &config_dir, ®istry).await?; + } + } + + Ok(()) +} + +async fn run( + args: RunArgs, + project_root: &std::path::Path, + config_dir: &std::path::Path, + registry: &[registry::Check], +) -> Result<()> { + let cfg = config::load(config_dir)?; + + // Filter registry to requested linters (or all if none specified). + // Explicit linter names override --fast-only (same behaviour as golangci-lint). + let explicit = !args.linters.is_empty(); + let checks: Vec<®istry::Check> = if explicit { + let mut out = vec![]; + for name in &args.linters { + match registry.iter().find(|c| c.name == name.as_str()) { + Some(c) => out.push(c), + None => { + eprintln!("flint: unknown linter: {name}"); + std::process::exit(1); + } + } + } + out + } else { + registry.iter().collect() + }; + + // 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. + let mise_tools = registry::read_mise_tools(project_root); + let active: Vec<®istry::Check> = { + let mut out = vec![]; + for c in checks { + if registry::check_active(c, &mise_tools) { + if explicit || !args.fast_only || c.category != registry::Category::Slow { + out.push(c); + } + } else if explicit { + eprintln!( + "flint: linter {name} is not active (binary not installed or not declared in mise.toml)", + name = c.name + ); + std::process::exit(1); + } + } + out + }; + + if args.verbose { + let names: Vec<&str> = active.iter().map(|c| c.name).collect(); + if names.is_empty() { + eprintln!("flint: no active linters"); + } else { + eprintln!("flint: active linters: {}", names.join(", ")); + } + } + + 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, + &mise_tools, + ) + .await?; + + let (fixable, reviewable): (Vec, Vec) = check_results + .into_iter() + .filter(|r| !r.ok) + .partition(|r| is_fixable(&r.name, &active)); + + let mut fixed = vec![]; + let mut fix_failed = vec![]; + if !fixable.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, + &file_list, + RunOptions { + fix: true, + verbose: false, + short: true, + time: false, + }, + project_root, + &cfg, + config_dir, + &mise_tools, + ) + .await?; + for r in fix_results { + if r.ok { + fixed.push(r.name); + } else { + fix_failed.push(r.name); + } + } + } + + // Emit linter output for checks that need manual review so the caller + // has the failure details without a second flint invocation. + for r in &reviewable { + eprintln!("[{}]", r.name); + if !r.stdout.is_empty() { + eprint!("{}", String::from_utf8_lossy(&r.stdout)); + } + if !r.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&r.stderr)); + } + } + + let remaining: Vec<&str> = reviewable + .iter() + .map(|r| r.name.as_str()) + .chain(fix_failed.iter().map(String::as_str)) + .collect(); + + let mut segments = vec![]; + if !fixed.is_empty() { + // Exit 1 even when fixes were applied: in a pre-push context the + // fixed files are uncommitted. The caller must commit them first. + segments.push(format!( + "fixed: {} — commit before pushing", + fixed.join(", ") + )); + } + if !remaining.is_empty() { + segments.push(format!("review: {}", remaining.join(", "))); + } + if !segments.is_empty() { + eprintln!("flint: {}", segments.join(" | ")); + std::process::exit(1); + } + return Ok(()); + } + + let results = runner::run( + &active, + &file_list, + RunOptions { + fix: false, + verbose: args.verbose, + short: args.short, + time: args.time, + }, + project_root, + &cfg, + config_dir, + &mise_tools, + ) + .await?; + + let failed: Vec<&str> = results + .iter() + .filter(|r| !r.ok) + .map(|r| r.name.as_str()) + .collect(); + + if !failed.is_empty() { + let n = failed.len(); + let noun = if n == 1 { "check" } else { "checks" }; + if args.short { + // Partition by fixability. Emit the exact command for fixable checks + // so AI callers can act without a reasoning step. + let (fixable, reviewable): (Vec<&str>, Vec<&str>) = failed + .iter() + .copied() + .partition(|name| is_fixable(name, &active)); + let mut segments = vec![]; + if !fixable.is_empty() { + segments.push(format!("flint run --fix {}", fixable.join(" "))); + } + if !reviewable.is_empty() { + segments.push(format!("review: {}", reviewable.join(", "))); + } + eprintln!("flint: {n} {noun} failed — {}", segments.join(" | ")); + } else { + eprintln!( + "\nflint: {n} {noun} failed ({names})", + names = failed.join(", ") + ); + eprintln!( + "💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify." + ); + } + std::process::exit(1); + } + + Ok(()) +} + +fn print_linters_json(registry: &[registry::Check]) { + let entries: Vec = registry.iter().map(linter_json).collect(); + println!("{}", serde_json::to_string_pretty(&entries).unwrap()); +} + +pub fn linter_json(check: ®istry::Check) -> serde_json::Value { + let scope = match &check.kind { + CheckKind::Template { scope, .. } => match scope { + Scope::File => "file", + Scope::Files => "files", + Scope::Project => "project", + }, + CheckKind::Special(_) => "special", + }; + let patterns: Vec<&str> = check.patterns.to_vec(); + let config_file: Option<&str> = check.linter_config.map(|(filename, _)| filename); + serde_json::json!({ + "name": check.name, + "description": check.desc, + "binary": if check.uses_binary() { check.bin_name } else { "(built-in)" }, + "patterns": patterns, + "fix": check.has_fix(), + "slow": check.category == registry::Category::Slow, + "scope": scope, + "config_file": config_file, + }) +} + +fn is_fixable(name: &str, active: &[®istry::Check]) -> bool { + active.iter().any(|c| c.name == name && c.has_fix()) +} + +fn print_linters( + registry: &[registry::Check], + mise_tools: &HashMap, + cfg: &config::Config, +) { + // Column widths. + let name_w = registry + .iter() + .map(|c| c.name.len()) + .max() + .unwrap_or(4) + .max(4); + let bin_w = registry + .iter() + .map(|c| c.bin_name.len()) + .max() + .unwrap_or(6) + .max(6); + let desc_w = registry + .iter() + .map(|c| c.desc.len()) + .max() + .unwrap_or(11) + .max(11); + + println!( + "{:, + /// Semver requirement string (e.g. `">=1.0.0"`). When `None`, any version matches. + /// When multiple registry entries share a `bin_name`, each must have a `version_range` + /// and the ranges must be non-overlapping and collectively exhaustive. + pub version_range: Option<&'static str>, + /// Glob patterns for matching files. + pub patterns: &'static [&'static str], + /// When any of these named checks are active, exclude their patterns from + /// this check's file list. Used to avoid double-checking files that a + /// dedicated formatter already owns. + pub excludes_if_active: &'static [&'static str], + pub category: Category, + /// When set, look for `(filename, flag)` in config_dir: if the file exists, inject + /// `flag ` into the command right after the binary name. + pub linter_config: Option<(&'static str, &'static str)>, + /// This check is a formatter — it owns certain file types for formatting purposes. + pub is_formatter: bool, + /// Skip files owned by active formatters (used by ec to avoid double-checking). + pub defers_to_formatters: bool, + /// Always considered active regardless of mise.toml (used for config-activated checks). + pub activate_unconditionally: bool, + /// Canonical mise tool key to write when setting up a new project (e.g. `npm:prettier`). + /// Optional mise toolchain components to request when installing via `flint init` + /// (e.g. `"clippy,rustfmt"` for the `rust` toolchain). Produces an inline-table + /// entry: `rust = { version = "latest", components = "clippy,rustfmt" }`. + pub mise_install_components: Option<&'static str>, + pub kind: CheckKind, + /// Binary name format when the backend installs with a versioned name (e.g. `"shfmt_{version}"` + /// → `"shfmt_v3.12.0"`). `{version}` is replaced with the version declared in mise.toml. + /// Paired with `mise_tool_name` when the backend names binaries with a version suffix. + pub versioned_bin_fmt: Option<&'static str>, + /// Plain-text description of what the check does — shown in `flint linters` and the README table. + pub desc: &'static str, + /// Extended markdown documentation shown in the README detail section (behaviour, config examples). + pub docs: &'static str, +} + +impl Check { + pub fn has_fix(&self) -> bool { + match &self.kind { + CheckKind::Template { fix_cmd, .. } => !fix_cmd.is_empty(), + CheckKind::Special(SpecialKind::Links) => false, + CheckKind::Special(SpecialKind::RenovateDeps) => true, + CheckKind::Special(SpecialKind::LicenseHeader) => false, + } + } + + /// Returns false for checks implemented entirely in-process with no external binary. + pub fn uses_binary(&self) -> bool { + !matches!(self.kind, CheckKind::Special(SpecialKind::LicenseHeader)) + } + + // --- Constructors --- + + /// Check invoked once per matched file (`{FILE}`). `name` is also used as `bin_name`. + pub fn file( + name: &'static str, + check_cmd: &'static str, + patterns: &'static [&'static str], + ) -> Self { + Self::template(name, patterns, check_cmd, Scope::File) + } + + /// Check invoked once with all matched files (`{FILES}`). `name` is also used as `bin_name`. + pub fn files( + name: &'static str, + check_cmd: &'static str, + patterns: &'static [&'static str], + ) -> Self { + Self::template(name, patterns, check_cmd, Scope::Files) + } + + /// Check invoked once per project (no file args). `name` is also used as `bin_name`. + pub fn project( + name: &'static str, + check_cmd: &'static str, + patterns: &'static [&'static str], + ) -> Self { + Self::template(name, patterns, check_cmd, Scope::Project) + } + + fn template( + name: &'static str, + patterns: &'static [&'static str], + check_cmd: &'static str, + scope: Scope, + ) -> Self { + Check { + name, + bin_name: name, + mise_tool_name: None, + version_range: None, + patterns, + excludes_if_active: &[], + linter_config: None, + is_formatter: false, + defers_to_formatters: false, + activate_unconditionally: false, + category: Category::Default, + mise_install_components: None, + kind: CheckKind::Template { + check_cmd, + fix_cmd: "", + full_cmd: "", + full_fix_cmd: "", + scope, + }, + versioned_bin_fmt: None, + desc: "", + docs: "", + } + } + + /// Special check with custom logic (not a simple command template). + pub fn special(name: &'static str, bin_name: &'static str, kind: SpecialKind) -> Self { + Check { + name, + bin_name, + mise_tool_name: None, + version_range: None, + patterns: &[], + excludes_if_active: &[], + linter_config: None, + is_formatter: false, + defers_to_formatters: false, + activate_unconditionally: false, + category: Category::Default, + mise_install_components: None, + kind: CheckKind::Special(kind), + versioned_bin_fmt: None, + desc: "", + docs: "", + } + } + + // --- Modifiers --- + + /// Override `bin_name` when the binary name differs from the check name + /// (e.g. `ruff-format` invokes `ruff`). + pub fn bin(mut self, bin_name: &'static str) -> Self { + self.bin_name = bin_name; + self + } + + /// Set the mise.toml tool key when the binary ships as part of a toolchain + /// (e.g. `cargo-fmt` ships with `rust`). + pub fn mise_tool(mut self, name: &'static str) -> Self { + self.mise_tool_name = Some(name); + self + } + + /// Add a fix command (auto-fix mode). + pub fn fix(mut self, fix_cmd: &'static str) -> Self { + if let CheckKind::Template { + fix_cmd: ref mut f, .. + } = self.kind + { + *f = fix_cmd; + } + self + } + + /// Set project-wide commands used instead of `check_cmd`/`fix_cmd` when + /// `file_list.full == true` (explicit `--full` or no merge base). Commands + /// run with no file arguments — useful for tools that discover files internally + /// (e.g. `cargo fmt`). Also handles edition detection for Rust tools. + pub fn full_cmd(mut self, check: &'static str, fix: &'static str) -> Self { + if let CheckKind::Template { + full_cmd: ref mut c, + full_fix_cmd: ref mut f, + .. + } = self.kind + { + *c = check; + *f = fix; + } + self + } + + /// Restrict activation to a semver range of the declared tool version. + #[allow(dead_code)] + pub fn version_req(mut self, range: &'static str) -> Self { + self.version_range = Some(range); + self + } + + /// Mark as slow — skipped when `--fast-only` is passed; `comprehensive` init profile only. + pub fn slow(mut self) -> Self { + self.category = Category::Slow; + self + } + + /// Mark as a formatter — files it owns are excluded from ec when both are active. + pub fn formatter(mut self) -> Self { + self.is_formatter = true; + self + } + + /// Skip files owned by active formatters (for ec — avoids double-checking). + pub fn defer_to_formatters(mut self) -> Self { + self.defers_to_formatters = true; + self + } + + /// Always considered active regardless of mise.toml (for config-activated checks). + pub fn activate_unconditionally(mut self) -> Self { + self.activate_unconditionally = true; + self + } + + /// Set a versioned binary name format for tools where the backend installs with a + /// version suffix (e.g. `"shfmt_{version}"` → `"shfmt_v3.12.0"`). Paired with + /// `.mise_tool()` to identify which key provides the version. + pub fn versioned_bin(mut self, fmt: &'static str) -> Self { + self.versioned_bin_fmt = Some(fmt); + self + } + + /// Set the plain-text description shown in `flint linters` and the README table. + pub fn desc(mut self, desc: &'static str) -> Self { + self.desc = desc; + self + } + + /// Set extended markdown documentation shown in the README detail section. + pub fn docs(mut self, docs: &'static str) -> Self { + self.docs = docs; + self + } + + /// Override the patterns field (useful for Special checks that need init-detection + /// patterns but don't use them for file matching at runtime). + pub fn patterns(mut self, patterns: &'static [&'static str]) -> Self { + self.patterns = patterns; + self + } + + /// Mark as a primary language analysis check — included in all init profiles. + pub fn lang(mut self) -> Self { + self.category = Category::Lang; + self + } + + /// Mark as a language-specific style/formatter check — included in all init profiles. + pub fn style(mut self) -> Self { + self.category = Category::Style; + self + } + + /// Set toolchain components required when installing via `flint init` + /// (e.g. `"clippy,rustfmt"` for the `rust` toolchain). + pub fn install_components(mut self, components: &'static str) -> Self { + self.mise_install_components = Some(components); + self + } + + /// Inject a config file from config_dir into the linter command. + /// If `config_dir/file` exists at runtime, `flag ` is inserted + /// right after the binary name. Has no effect when the file is absent. + pub fn linter_config(mut self, file: &'static str, flag: &'static str) -> Self { + self.linter_config = Some((file, flag)); + self + } +} + +/// Built-in linter registry. +/// +/// # Naming convention +/// +/// A check's `name` is the last path segment of its mise tool key (after `:` or `/`): +/// - `editorconfig-checker` → name `editorconfig-checker` (not the binary `ec`) +/// - `npm:markdownlint-cli2` → name `markdownlint-cli2` +/// - `github:pinterest/ktlint` → name `ktlint` +/// +/// Exception: when the mise tool key is a language toolchain shared across multiple +/// binaries (e.g. `rust`, `go`, `dotnet`), use the binary name instead — the toolchain +/// name would be ambiguous (`rust` can't name both `cargo-fmt` and `cargo-clippy`). +fn check_shellcheck() -> Check { + Check::file( + "shellcheck", + "shellcheck {FILE}", + &["*.sh", "*.bash", "*.bats"], + ) + .linter_config(".shellcheckrc", "--rcfile") + .desc("Lint shell scripts for common mistakes") + .style() +} + +fn check_shfmt() -> Check { + Check::file("shfmt", "shfmt -d {FILE}", &["*.sh", "*.bash"]) + .fix("shfmt -w {FILE}") + .formatter() + .mise_tool("github:mvdan/sh") + .versioned_bin("shfmt_{version}") + .desc("Format shell scripts") + .style() +} + +fn check_markdownlint_cli2() -> Check { + Check::file("markdownlint-cli2", "markdownlint-cli2 {FILE}", &["*.md"]) + .fix("markdownlint-cli2 --fix {FILE}") + .linter_config(".markdownlint.jsonc", "--config") + .desc("Lint Markdown files for style and consistency") + .mise_tool("npm:markdownlint-cli2") +} + +fn check_prettier() -> Check { + Check::files( + "prettier", + "prettier --check {FILES}", + &["*.md", "*.yml", "*.yaml"], + ) + .fix("prettier --write {FILES}") + .full_cmd("prettier --check {ROOT}", "prettier --write {ROOT}") + .linter_config(".prettierrc", "--config") + .formatter() + .desc("Format Markdown and YAML files") + .mise_tool("npm:prettier") +} + +fn check_actionlint() -> Check { + Check::file( + "actionlint", + "actionlint {FILE}", + &[".github/workflows/*.yml", ".github/workflows/*.yaml"], + ) + .linter_config("actionlint.yml", "-config-file") + .desc("Lint GitHub Actions workflow files") + .style() +} + +fn check_hadolint() -> Check { + Check::file( + "hadolint", + "hadolint {FILE}", + &["Dockerfile", "Dockerfile.*", "*.dockerfile"], + ) + .linter_config(".hadolint.yaml", "--config") + .desc("Lint Dockerfiles") + .style() +} + +fn check_xmllint() -> Check { + Check::files("xmllint", "xmllint --noout {FILES}", &["*.xml"]) + .mise_tool("cargo:xmloxide") + .mise_tool("cargo:xmloxide") + .desc("Validate XML files are well-formed") +} + +fn check_codespell() -> Check { + Check::files("codespell", "codespell {FILES}", &["*"]) + .fix("codespell --write-changes {FILES}") + .linter_config(".codespellrc", "--config") + .desc("Check for common spelling mistakes") + .mise_tool("pipx:codespell") +} + +fn check_editorconfig_checker() -> Check { + // Defer to formatters that enforce line length — those are the ones + // that conflict with ec's max_line_length editorconfig check. + // Note: ec's -config flag controls ec's own JSON config, not .editorconfig itself. + Check::files("editorconfig-checker", "ec {FILES}", &["*"]) + .bin("ec") + .mise_tool("editorconfig-checker") + .defer_to_formatters() + .linter_config(".editorconfig-checker.json", "-config") + .desc("Check files comply with EditorConfig settings") +} + +fn check_golangci_lint() -> Check { + Check::project( + "golangci-lint", + "golangci-lint run --new-from-rev={MERGE_BASE}", + &["*.go"], + ) + .linter_config(".golangci.yml", "--config") + .desc("Lint Go code; uses --new-from-rev to scope analysis to changed code") + .lang() +} + +fn check_ruff() -> Check { + Check::file("ruff", "ruff check {FILE}", &["*.py"]) + .fix("ruff check --fix {FILE}") + .linter_config("ruff.toml", "--config") + .desc("Lint Python code") + .mise_tool("pipx:ruff") + .lang() +} + +fn check_ruff_format() -> Check { + Check::file("ruff-format", "ruff format --check {FILE}", &["*.py"]) + .bin("ruff") + .fix("ruff format {FILE}") + .linter_config("ruff.toml", "--config") + .formatter() + .desc("Format Python code") + .mise_tool("pipx:ruff") + .lang() +} + +fn check_biome() -> Check { + Check::file( + "biome", + "biome check {FILE}", + &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"], + ) + .fix("biome check --fix {FILE}") + .desc("Lint JS/TS/JSON files") + .mise_tool("npm:@biomejs/biome") + .lang() +} + +fn check_biome_format() -> Check { + Check::file( + "biome-format", + "biome format {FILE}", + &["*.json", "*.jsonc", "*.js", "*.ts", "*.jsx", "*.tsx"], + ) + .bin("biome") + .fix("biome format --write {FILE}") + .formatter() + .desc("Format JS/TS/JSON files") + .mise_tool("npm:@biomejs/biome") + .lang() +} + +fn check_cargo_clippy() -> Check { + Check::project("cargo-clippy", "cargo clippy -q -- -D warnings", &["*.rs"]) + .fix("cargo clippy -q --fix --allow-dirty --allow-staged -- -D warnings") + .mise_tool("rust") + .install_components("clippy") + .desc("Lint Rust code; runs on all .rs files, not just changed") + .lang() +} + +fn check_cargo_fmt() -> Check { + Check::project("cargo-fmt", "cargo fmt -- --check", &["*.rs"]) + .fix("cargo fmt") + .bin("rustfmt") + .mise_tool("rust") + .install_components("rustfmt") + .formatter() + .desc("Format Rust code; runs on all .rs files, not just changed") + .lang() +} + +fn check_gofmt() -> Check { + Check::file("gofmt", "gofmt -d {FILE}", &["*.go"]) + .fix("gofmt -w {FILE}") + .mise_tool("go") + .formatter() + .desc("Format Go code") + .lang() +} + +fn check_google_java_format() -> Check { + Check::files( + "google-java-format", + "google-java-format --dry-run --set-exit-if-changed {FILES}", + &["*.java"], + ) + .fix("google-java-format -i {FILES}") + .mise_tool("github:google/google-java-format") + .formatter() + .desc("Format Java code") + .lang() +} + +fn check_ktlint() -> Check { + Check::files( + "ktlint", + "ktlint --log-level=error {FILES}", + &["*.kt", "*.kts"], + ) + .fix("ktlint --format --log-level=error {FILES}") + .full_cmd( + "ktlint --log-level=error {ROOT}", + "ktlint --format --log-level=error {ROOT}", + ) + .mise_tool("github:pinterest/ktlint") + .formatter() + .desc("Lint and format Kotlin code") + .lang() +} + +fn check_dotnet_format() -> Check { + Check::files( + "dotnet-format", + "dotnet format --verify-no-changes --include {RELFILES}", + &["*.cs"], + ) + .fix("dotnet format --include {RELFILES}") + .full_cmd("dotnet format --verify-no-changes", "dotnet format") + .bin("dotnet") + .mise_tool("dotnet") + .formatter() + .desc("Format C# code") + .lang() +} + +fn check_lychee() -> Check { + Check::special("lychee", "lychee", SpecialKind::Links) + .desc("Check for broken links") + .docs( + "Orchestrates [lychee](https://lychee.cli.rs/) for link checking. \ + Requires `lychee` in `[tools]`.\n\ + \n\ + Default behavior: checks all links in changed files. \ + When `check_all_local = true` in `flint.toml`, adds a second pass \ + over local links in all files — useful when broken internal links \ + from unchanged files also matter.\n\ + \n\ + Configure via `flint.toml`:\n\ + \n\ + ```toml\n\ + [checks.links]\n\ + config = \".github/config/lychee.toml\"\n\ + check_all_local = true\n\ + ```", + ) +} + +fn check_renovate_deps() -> Check { + Check::special("renovate-deps", "renovate", SpecialKind::RenovateDeps) + .mise_tool("npm:renovate") + .patterns(RENOVATE_CONFIG_PATTERNS) + .desc("Verify Renovate dependency snapshot is up to date") + .docs( + "Verifies `.github/renovate-tracked-deps.json` is up to date by running \ + Renovate locally and comparing its output against the committed snapshot. \ + Requires `renovate` in `[tools]`.\n\ + \n\ + With `--fix`, automatically regenerates and commits the snapshot.\n\ + \n\ + Configure via `flint.toml`:\n\ + \n\ + ```toml\n\ + [checks.renovate-deps]\n\ + exclude_managers = [\"github-actions\", \"github-runners\"]\n\ + ```", + ) +} + +fn check_license_header() -> Check { + Check::special( + "license-header", + "license-header", + SpecialKind::LicenseHeader, + ) + .activate_unconditionally() + .desc("Check source files have the required license header") +} + +pub fn builtin() -> Vec { + vec![ + check_shellcheck(), + check_shfmt(), + check_markdownlint_cli2(), + check_prettier(), + check_actionlint(), + check_hadolint(), + check_xmllint(), + check_codespell(), + check_editorconfig_checker(), + check_golangci_lint(), + check_ruff(), + check_ruff_format(), + check_biome(), + check_biome_format(), + check_cargo_clippy(), + check_cargo_fmt(), + check_gofmt(), + check_google_java_format(), + check_ktlint(), + check_dotnet_format(), + check_lychee(), + check_renovate_deps(), + check_license_header(), + ] +} + +/// Mise tool keys that are no longer supported by flint and should be removed +/// during `flint init`. Each entry is `(old_key, replacement_key)` where +/// `replacement_key` is the modern equivalent that the registry now uses. +pub const OBSOLETE_KEYS: &[(&str, &str)] = &[ + // markdownlint-cli was superseded by markdownlint-cli2 (actively maintained, + // faster, supports the same config files). flint only supports the cli2 variant. + ("npm:markdownlint-cli", "npm:markdownlint-cli2"), +]; + +/// Reads `[tools]` from the consuming repo's mise.toml and returns a map of +/// tool name → declared version string. +/// +/// Also registers normalized aliases for backend-prefixed tools so that checks +/// can match by their bare package/binary name. For example: +/// - `"npm:prettier"` → also registers `"prettier"` +/// - `"npm:@biomejs/biome"` → also registers `"biome"` (last path component) +/// - `"github:google/google-java-format"` → also registers `"google-java-format"` +/// +/// The original key is always preserved; aliases only fill in missing entries. +pub fn read_mise_tools(project_root: &Path) -> HashMap { + let path = project_root.join("mise.toml"); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return HashMap::new(), + }; + let value: toml::Value = match toml::from_str(&content) { + Ok(v) => v, + Err(_) => return HashMap::new(), + }; + let mut tools = HashMap::new(); + if let Some(table) = value.get("tools").and_then(|v| v.as_table()) { + for (name, val) in table { + let version = match val { + toml::Value::String(s) => Some(s.clone()), + toml::Value::Table(t) => { + t.get("version").and_then(|v| v.as_str()).map(String::from) + } + _ => None, + }; + if let Some(v) = version { + tools.insert(name.clone(), v); + } + } + } + // Add normalized aliases: strip the backend prefix (e.g. "npm:", "pipx:", "ubi:") + // and take the last path component (e.g. "@biomejs/biome" → "biome"). + // Aliases never override an explicitly declared entry. + let aliases: Vec<(String, String)> = tools + .iter() + .filter_map(|(k, v)| { + let (_, rest) = k.split_once(':')?; + let base = rest.rsplit('/').next().unwrap_or(rest); + Some((base.to_string(), v.clone())) + }) + .collect(); + for (alias, version) in aliases { + tools.entry(alias).or_insert(version); + } + tools +} + +/// Returns true if the check's tool is declared in mise.toml and its version +/// satisfies the check's version_range (if any). +pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool { + if check.activate_unconditionally { + return true; + } + let lookup_key = check.mise_tool_name.unwrap_or(check.bin_name); + // When mise_tool_name is set (e.g. "npm:markdownlint-cli2"), also accept + // the bare bin_name ("markdownlint-cli2") so repos using either form work. + let declared = mise_tools + .get(lookup_key) + .or_else(|| check.mise_tool_name.and(mise_tools.get(check.bin_name))); + let Some(declared) = declared else { + return false; + }; + let Some(range_str) = check.version_range else { + return true; + }; + let Ok(req) = semver::VersionReq::parse(range_str) else { + return false; + }; + coerce_version(declared).is_some_and(|v| req.matches(&v)) +} + +/// Returns the binary name to use for this check given the active mise tools. +/// When `versioned_bin_fmt` is set, the version from mise.toml is substituted +/// into the format string (e.g. `"shfmt_{version}"` + `"v3.12.0"` → `"shfmt_v3.12.0"`). +/// Falls back to `check.bin_name` for standard installations. +pub fn resolve_bin_name(check: &Check, mise_tools: &HashMap) -> String { + if let Some(fmt) = check.versioned_bin_fmt { + let key = check.mise_tool_name.unwrap_or(check.bin_name); + if let Some(version) = mise_tools.get(key) { + return fmt.replace("{version}", version); + } + } + check.bin_name.to_string() +} + +/// Returns true if `bin_name` exists as a file in any directory in `path_var` +/// (a `:`-separated PATH string). Accepts the PATH string as a parameter so +/// callers can substitute a test-controlled path without mutating env vars. +pub fn binary_on_path_var(bin_name: &str, path_var: &str) -> bool { + std::env::split_paths(path_var).any(|dir| dir.join(bin_name).is_file()) +} + +/// Returns true if `bin_name` is found in the current `PATH`. +pub fn binary_on_path(bin_name: &str) -> bool { + binary_on_path_var(bin_name, &std::env::var("PATH").unwrap_or_default()) +} + +/// Parses a version string, padding with `.0` components if needed to satisfy +/// semver's three-part requirement (e.g. `"20"` → `20.0.0`, `"3.12"` → `3.12.0`). +fn coerce_version(s: &str) -> Option { + semver::Version::parse(s).ok().or_else(|| { + let parts = s.split('.').count(); + match parts { + 1 => semver::Version::parse(&format!("{s}.0.0")).ok(), + 2 => semver::Version::parse(&format!("{s}.0")).ok(), + _ => None, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// If any entry for a bin_name declares a version_range, every entry for that + /// bin_name must declare one. A mix of ranged and unranged entries for the same + /// binary is ambiguous — it would be impossible to guarantee exactly one activates. + /// (Multiple unranged entries for the same binary are fine: they're different + /// subcommand invocations of the same tool, e.g. `biome check` vs `biome format`.) + #[test] + fn version_ranges_must_not_be_mixed_with_unranged_entries() { + let registry = builtin(); + let mut by_bin: HashMap<&str, Vec<&Check>> = HashMap::new(); + for check in ®istry { + by_bin.entry(check.bin_name).or_default().push(check); + } + for (bin, checks) in &by_bin { + let any_ranged = checks.iter().any(|c| c.version_range.is_some()); + if any_ranged { + for check in checks { + assert!( + check.version_range.is_some(), + "check '{}' shares bin_name '{}' with version-ranged entries but has no version_range", + check.name, + bin, + ); + } + } + } + } + + /// Checks that every linter in the registry that uses an external binary + /// actually has that binary on PATH. Covers all registry entries, not just + /// those active in this repo — so tools like ktlint and hadolint are checked + /// even if they are not declared in this repo's mise.toml. + /// + /// This test will fail on machines where not all linter tools are installed, + /// which is intentional: it identifies what is missing. + #[test] + fn all_registry_binaries_found() { + let registry = builtin(); + let mise_tools = read_mise_tools(Path::new(env!("CARGO_MANIFEST_DIR"))); + + let not_found: Vec<&str> = registry + .iter() + .filter(|c| c.uses_binary()) + .filter(|c| !binary_on_path(&resolve_bin_name(c, &mise_tools))) + .map(|c| c.name) + .collect(); + + assert!( + not_found.is_empty(), + "registry linters missing binary on PATH: {}", + not_found.join(", ") + ); + } + + /// Verifies the README linter table is in sync with the registry. + /// Every column is checked against the registry except `config_file`, which + /// may contain hand-written footnotes or prose (e.g. the lychee config note). + /// + /// Run `UPDATE_README=1 cargo test readme_linter_table_in_sync` to regenerate. + #[test] + fn readme_linter_table_in_sync() { + let readme_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"); + let readme = std::fs::read_to_string(&readme_path).expect("README.md must be readable"); + let registry = builtin(); + + let expected = generate_readme_table(®istry); + + if std::env::var("UPDATE_README").is_ok() { + let updated = replace_readme_table(&readme, &expected); + std::fs::write(&readme_path, updated).expect("failed to write README.md"); + return; + } + + // Normalize both sides: strip blank lines that prettier adds around + // headings, tables, and code blocks. This keeps the comparison stable + // even when docs contain multi-paragraph content with blank lines. + let actual = extract_readme_table(&readme); + let expected_norm = strip_blank_lines(&expected); + if actual != expected_norm { + panic!( + "README linter table is out of sync with the registry.\n\ + Run `UPDATE_README=1 cargo test readme_linter_table_in_sync` to regenerate.\n\n\ + Expected:\n{expected_norm}\n\nActual:\n{actual}" + ); + } + } + + const README_TABLE_START: &str = ""; + const README_TABLE_END: &str = ""; + + fn strip_blank_lines(s: &str) -> String { + s.lines() + .filter(|l| !l.trim().is_empty()) + .collect::>() + .join("\n") + } + + fn extract_readme_table(readme: &str) -> String { + let start = readme + .find(README_TABLE_START) + .expect("README missing marker") + + README_TABLE_START.len(); + let end = readme + .find(README_TABLE_END) + .expect("README missing marker"); + // Strip blank lines that prettier inserts around headings, tables, and + // code blocks — and that linter docs contain between paragraphs. + strip_blank_lines(&readme[start..end]) + } + + fn replace_readme_table(readme: &str, table: &str) -> String { + // `start` points just after the opening marker; `&readme[..start]` includes it. + let start = readme + .find(README_TABLE_START) + .expect("README missing marker") + + README_TABLE_START.len(); + let end = readme + .find(README_TABLE_END) + .expect("README missing marker"); + format!( + "{}\n{}\n{}{}", + &readme[..start], + table, + README_TABLE_END, + &readme[end + README_TABLE_END.len()..] + ) + } + + fn generate_readme_table(registry: &[Check]) -> String { + let generated_comment = ""; + + // Summary table: Name | Description | Fix — sorted alphabetically. + let headers = ["Name", "Description", "Fix"]; + let mut sorted: Vec<&Check> = registry.iter().collect(); + sorted.sort_by_key(|c| c.name); + let rows: Vec<[String; 3]> = sorted.iter().map(|c| summary_row(c)).collect(); + + let mut widths = headers.map(|h| h.len()); + for row in &rows { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell.len()); + } + } + let fmt_row = |cells: &[&str]| -> String { + let cols: Vec = cells + .iter() + .enumerate() + .map(|(i, cell)| format!("{: = widths.iter().map(|&w| "-".repeat(w)).collect(); + let sep_row = format!("| {} |", separator.join(" | ")); + let header_strs: Vec<&str> = headers.iter().copied().collect(); + + let mut lines = vec![ + generated_comment.to_string(), + fmt_row(&header_strs), + sep_row, + ]; + for row in &rows { + let strs: Vec<&str> = row.iter().map(|s| s.as_str()).collect(); + lines.push(fmt_row(&strs)); + } + + // Per-linter detail sections (alphabetical) + for check in &sorted { + lines.push(format!("#### `{}`", check.name)); + lines.push(detail_table(check)); + } + + lines.join("\n") + } + + fn summary_row(check: &Check) -> [String; 3] { + let name = format!("`{}`", check.name); + let desc = if check.desc.is_empty() { + "—".to_string() + } else { + check.desc.to_string() + }; + let fix = if check.has_fix() { "yes" } else { "—" }.to_string(); + [name, desc, fix] + } + + fn detail_table(check: &Check) -> String { + let rows = detail_rows(check); + + let col1_w = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + let col2_w = rows.iter().map(|(_, v)| v.len()).max().unwrap_or(0); + + let fmt = |k: &str, v: &str| format!("| {: Vec<(&'static str, String)> { + let mut rows: Vec<(&'static str, String)> = vec![]; + + if !check.desc.is_empty() { + rows.push(("Description", check.desc.to_string())); + } + + rows.push(( + "Fix", + if check.has_fix() { "yes" } else { "no" }.to_string(), + )); + + let binary = if check.uses_binary() { + format!("`{}`", check.bin_name) + } else { + "(built-in)".to_string() + }; + rows.push(("Binary", binary)); + + let scope = match &check.kind { + CheckKind::Template { scope, .. } => match scope { + Scope::File => "file", + Scope::Files => "files", + Scope::Project => "project", + }, + CheckKind::Special(_) => "special", + }; + rows.push(("Scope", format!("[{scope}](#scopes)"))); + + if !check.patterns.is_empty() { + rows.push(("Patterns", format!("`{}`", check.patterns.join(" ")))); + } + + match check.linter_config { + Some((filename, _)) => rows.push(("Config", format!("`{filename}`"))), + None => { + if matches!(&check.kind, CheckKind::Special(SpecialKind::Links)) { + rows.push(("Config", "via `[checks.links]` in flint.toml".to_string())); + } + } + } + + if check.category == Category::Slow { + rows.push(("Slow", "yes — skipped by `--fast-only`".to_string())); + } + + rows + } + + /// Smoke test: every check whose tool key resolves in this repo's expanded + /// mise_tools map must pass check_active. This catches tool-name mismatches + /// (wrong lookup key) and version-range violations without a hardcoded list — + /// new registry entries are covered automatically. + #[test] + fn all_flint_repo_linters_detected() { + let project_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mise_tools = read_mise_tools(project_root); + let registry = builtin(); + + let inactive: Vec<&str> = registry + .iter() + .filter(|c| { + // A check is "expected" if its lookup key appears in the expanded + // mise_tools map, or if it activates unconditionally. + c.activate_unconditionally || { + let lookup = c.mise_tool_name.unwrap_or(c.bin_name); + mise_tools.contains_key(lookup) + } + }) + .filter(|c| !check_active(c, &mise_tools)) + .map(|c| c.name) + .collect(); + + assert!( + inactive.is_empty(), + "linters not detected in flint repo: {}", + inactive.join(", ") + ); + } +} diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 0000000..fc25fdd --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,774 @@ +use anyhow::Result; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; +use tokio::task::JoinSet; + +use crate::config::{Config, LicenseHeaderConfig, LycheeConfig, RenovateDepsConfig}; +use crate::files::FileList; +use crate::linters::{LinterOutput, license_header, lychee, renovate_deps}; +use crate::registry::{self, Check, CheckKind, Scope, SpecialKind}; + +pub struct RunOptions { + pub fix: bool, + pub verbose: bool, + pub short: bool, + pub time: bool, +} + +pub struct CheckResult { + pub name: String, + pub ok: bool, + pub stdout: Vec, + pub stderr: Vec, + pub duration: Duration, +} + +/// A check with all inputs pre-resolved, ready to execute without borrowing +/// the registry or config. Built by `prepare()` before the fix/check split. +enum PreparedCheck { + Invocations { + name: String, + argv_list: Vec>, + }, + Links { + name: String, + cfg: LycheeConfig, + file_list: FileList, + config_dir: PathBuf, + }, + RenovateDeps { + name: String, + cfg: RenovateDepsConfig, + }, + LicenseHeader { + name: String, + cfg: LicenseHeaderConfig, + files: Vec, + }, +} + +impl PreparedCheck { + fn name(&self) -> &str { + match self { + Self::Invocations { name, .. } + | Self::Links { name, .. } + | Self::RenovateDeps { name, .. } + | Self::LicenseHeader { name, .. } => name, + } + } + + async fn execute(self, fix: bool, project_root: &Path) -> CheckResult { + let name = self.name().to_string(); + let start = Instant::now(); + let out: LinterOutput = match self { + Self::Invocations { argv_list, .. } => { + run_invocations(&name, &argv_list, project_root).await + } + Self::Links { + cfg, + file_list, + config_dir, + .. + } => lychee::run(&cfg, &file_list, project_root, &config_dir).await, + Self::RenovateDeps { cfg, .. } => renovate_deps::run(&cfg, fix, project_root).await, + Self::LicenseHeader { cfg, files, .. } => { + license_header::run(&cfg, project_root, &files).await + } + }; + CheckResult { + name, + ok: out.ok, + stdout: out.stdout, + stderr: out.stderr, + duration: start.elapsed(), + } + } +} + +pub async fn run( + checks: &[&Check], + file_list: &FileList, + opts: RunOptions, + project_root: &Path, + cfg: &Config, + config_dir: &Path, + mise_tools: &std::collections::HashMap, +) -> Result> { + let RunOptions { + fix, + verbose, + short, + time, + } = opts; + let prepared: Vec = checks + .iter() + .filter_map(|&check| { + prepare( + check, + file_list, + fix, + project_root, + checks, + cfg, + config_dir, + mise_tools, + ) + }) + .collect(); + + if fix { + let mut results = vec![]; + for task in prepared { + let r = task.execute(fix, project_root).await; + if !short && (verbose || !r.ok) { + eprintln!("[{}]{}", r.name, format_duration_suffix(time, r.duration)); + flush_output(&r.stdout, &r.stderr); + } + results.push(r); + } + return Ok(results); + } + + let mut set: JoinSet = JoinSet::new(); + for task in prepared { + let root = project_root.to_path_buf(); + set.spawn(async move { task.execute(false, &root).await }); + } + + // Collect all results before printing to avoid interleaved output. + // Sort by name for deterministic output order. + let mut collected = vec![]; + while let Some(res) = set.join_next().await { + collected.push(res?); + } + collected.sort_by(|a, b| a.name.cmp(&b.name)); + + if !short { + for r in &collected { + if verbose || !r.ok || time { + eprintln!("[{}]{}", r.name, format_duration_suffix(time, r.duration)); + } + if verbose || !r.ok { + flush_output(&r.stdout, &r.stderr); + } + } + } + + Ok(collected) +} + +#[allow(clippy::too_many_arguments)] +fn prepare( + check: &Check, + file_list: &FileList, + fix: bool, + project_root: &Path, + active_checks: &[&Check], + cfg: &Config, + config_dir: &Path, + mise_tools: &std::collections::HashMap, +) -> Option { + let name = check.name.to_string(); + match &check.kind { + CheckKind::Template { .. } => { + let argv_list = build_invocations( + check, + file_list, + fix, + project_root, + active_checks, + config_dir, + mise_tools, + ); + if argv_list.is_empty() { + return None; + } + Some(PreparedCheck::Invocations { name, argv_list }) + } + CheckKind::Special(SpecialKind::Links) => Some(PreparedCheck::Links { + name, + cfg: cfg.checks.lychee.clone(), + file_list: file_list.clone(), + config_dir: config_dir.to_path_buf(), + }), + CheckKind::Special(SpecialKind::RenovateDeps) => Some(PreparedCheck::RenovateDeps { + name, + cfg: cfg.checks.renovate_deps.clone(), + }), + CheckKind::Special(SpecialKind::LicenseHeader) => { + if cfg.checks.license_header.text.is_empty() { + return None; + } + let patterns: Vec<&str> = cfg + .checks + .license_header + .patterns + .iter() + .map(String::as_str) + .collect(); + let files: Vec = match_files(&file_list.files, &patterns, &[], project_root) + .into_iter() + .cloned() + .collect(); + if files.is_empty() { + return None; + } + Some(PreparedCheck::LicenseHeader { + name, + cfg: cfg.checks.license_header.clone(), + files, + }) + } + } +} + +/// Returns the list of argv vectors to execute for a check. +fn build_invocations( + check: &Check, + file_list: &FileList, + fix: bool, + project_root: &Path, + active_checks: &[&Check], + config_dir: &Path, + mise_tools: &std::collections::HashMap, +) -> Vec> { + let resolved_bin = registry::resolve_bin_name(check, mise_tools); + let CheckKind::Template { + check_cmd, + fix_cmd, + full_cmd, + full_fix_cmd, + scope, + } = &check.kind + else { + return vec![]; + }; + + // Substitute resolved binary name at the start of each command template. + // When installed via alt_install (e.g. "github:mvdan/sh" → "shfmt_v3.12.0"), + // the template's leading binary name must be replaced before execution. + let sub_bin = |t: &str| -> String { + if resolved_bin == check.bin_name { + return t.to_string(); + } + match t.strip_prefix(check.bin_name) { + Some(rest) if rest.is_empty() || rest.starts_with(' ') => { + format!("{}{}", resolved_bin, rest) + } + _ => t.to_string(), + } + }; + + let cmd_template_buf; + let cmd_template: &str = if fix && check.has_fix() { + cmd_template_buf = sub_bin(fix_cmd); + &cmd_template_buf + } else { + cmd_template_buf = sub_bin(check_cmd); + &cmd_template_buf + }; + + // Collect patterns from checks that are active and listed in excludes_if_active. + let mut excludes: Vec<&str> = active_checks + .iter() + .filter(|c| check.excludes_if_active.contains(&c.name)) + .flat_map(|c| c.patterns.iter().copied()) + .collect(); + + // When this check defers to formatters, also exclude files owned by active formatters. + if check.defers_to_formatters { + for active in active_checks.iter().filter(|c| c.is_formatter) { + excludes.extend(active.patterns.iter().copied()); + } + } + + let config_args = resolve_linter_config(check, config_dir); + + match scope { + Scope::Project => { + // If patterns are set, only run when relevant files are present. + if !check.patterns.is_empty() + && match_files(&file_list.files, check.patterns, &excludes, project_root).is_empty() + { + return vec![]; + } + let cmd = substitute_merge_base(cmd_template, file_list.merge_base.as_deref()); + vec![inject_config(shell_words(cmd), &config_args)] + } + + Scope::File => { + let matched = match_files(&file_list.files, check.patterns, &excludes, project_root); + matched + .iter() + .map(|f| { + let cmd = cmd_template.replace("{FILE}", "e_path(f)); + inject_config(shell_words(cmd), &config_args) + }) + .collect() + } + + Scope::Files => { + let matched = match_files(&file_list.files, check.patterns, &excludes, project_root); + if matched.is_empty() { + return vec![]; + } + // When all project files are in scope and a full_cmd is set, use it as a + // project-wide command instead of passing a (potentially huge) file list. + if file_list.full { + let effective = if fix && !full_fix_cmd.is_empty() { + Some(*full_fix_cmd) + } else if !fix && !full_cmd.is_empty() { + Some(*full_cmd) + } else { + None + }; + if let Some(cmd) = effective { + let cmd = sub_bin(cmd).replace("{ROOT}", "e_path(project_root)); + return vec![inject_config(shell_words(cmd), &config_args)]; + } + } + let edition_flag = resolve_cargo_edition_flag(project_root); + let files_arg: String = matched + .iter() + .map(|f| quote_path(f)) + .collect::>() + .join(" "); + let rel_files_arg: String = matched + .iter() + .map(|f| quote_path(f.strip_prefix(project_root).unwrap_or(f))) + .collect::>() + .join(" "); + let cmd = cmd_template + .replace("{CARGO_EDITION_FLAG}", &edition_flag) + .replace("{FILES}", &files_arg) + .replace("{RELFILES}", &rel_files_arg); + vec![inject_config(shell_words(cmd), &config_args)] + } + } +} + +/// Returns `--edition ` if a Rust edition is declared in the project's +/// `Cargo.toml`, or an empty string if not found. Used to substitute +/// `{CARGO_EDITION_FLAG}` in rustfmt command templates. +fn resolve_cargo_edition_flag(project_root: &Path) -> String { + let Ok(content) = std::fs::read_to_string(project_root.join("Cargo.toml")) else { + return String::new(); + }; + let Ok(doc) = content.parse::() else { + return String::new(); + }; + let edition = doc + .get("package") + .and_then(|p| p.get("edition")) + .and_then(|e| e.as_str()) + .or_else(|| { + doc.get("workspace") + .and_then(|w| w.get("package")) + .and_then(|p| p.get("edition")) + .and_then(|e| e.as_str()) + }); + edition + .map(|e| format!("--edition {e}")) + .unwrap_or_default() +} + +/// Returns `[flag, abs-path]` if `check.linter_config` is set and the file exists +/// in `config_dir`, otherwise an empty slice. +fn resolve_linter_config(check: &Check, config_dir: &Path) -> Vec { + let Some((file, flag)) = check.linter_config else { + return vec![]; + }; + let path = config_dir.join(file); + if !path.exists() { + return vec![]; + } + vec![flag.to_string(), path.to_string_lossy().into_owned()] +} + +/// Inserts `config_args` at position 1 (right after the binary name) in `argv`. +fn inject_config(mut argv: Vec, config_args: &[String]) -> Vec { + if config_args.is_empty() || argv.is_empty() { + return argv; + } + // Insert after argv[0] (the binary name). + let tail = argv.split_off(1); + argv.extend_from_slice(config_args); + argv.extend(tail); + argv +} + +/// Runs all invocations for one check. +/// Never prints — callers decide when and whether to flush output. +async fn run_invocations(name: &str, invocations: &[Vec], root: &Path) -> LinterOutput { + let mut all_ok = true; + let mut combined_stdout = Vec::new(); + let mut combined_stderr = Vec::new(); + + for argv in invocations { + if argv.is_empty() { + continue; + } + let result = crate::linters::spawn_command(argv) + .current_dir(root) + .stdin(Stdio::null()) + .output() + .await; + match result { + Ok(out) => { + combined_stdout.extend_from_slice(&out.stdout); + combined_stderr.extend_from_slice(&out.stderr); + if !out.status.success() { + all_ok = false; + } + } + Err(e) => { + combined_stderr + .extend_from_slice(format!("flint: {name}: failed to spawn: {e}\n").as_bytes()); + all_ok = false; + } + } + } + + maybe_append_rust_component_note(name, &mut combined_stderr); + + LinterOutput { + ok: all_ok, + stdout: combined_stdout, + stderr: combined_stderr, + } +} + +fn maybe_append_rust_component_note(name: &str, stderr: &mut Vec) { + let Some(component) = missing_rust_component(name, stderr) else { + return; + }; + let note = format!( + "NOTE: `{name}` needs the Rust `{component}` component in the active toolchain.\n\ +`mise` may activate an existing Rust toolchain without adding missing components.\n\ +Install it with: `rustup component add {component}`\n" + ); + stderr.extend_from_slice(note.as_bytes()); +} + +fn missing_rust_component(name: &str, stderr: &[u8]) -> Option<&'static str> { + let stderr = String::from_utf8_lossy(stderr); + match name { + "cargo-clippy" if stderr.contains("'cargo-clippy' is not installed for the toolchain") => { + Some("clippy") + } + "cargo-fmt" if stderr.contains("'rustfmt' is not installed for the toolchain") => { + Some("rustfmt") + } + _ => None, + } +} + +fn format_duration_suffix(time: bool, duration: Duration) -> String { + if !time { + return String::new(); + } + let ms = duration.as_millis(); + if ms < 1000 { + format!(" {ms}ms") + } else { + format!(" {:.1}s", duration.as_secs_f64()) + } +} + +fn flush_output(stdout: &[u8], stderr: &[u8]) { + // All tool output goes to stderr so headers and diagnostics stay on the + // same stream — callers (humans and AI alike) see a coherent sequence. + if !stdout.is_empty() { + eprint!("{}", String::from_utf8_lossy(stdout)); + } + if !stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(stderr)); + } +} + +fn match_files<'a>( + files: &'a [PathBuf], + patterns: &[&str], + exclude_patterns: &[&str], + project_root: &Path, +) -> Vec<&'a PathBuf> { + files + .iter() + .filter(|p| { + let rel = p.strip_prefix(project_root).unwrap_or(p); + let rel_str = rel.to_string_lossy(); + let file_name = p + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + let included = patterns.iter().any(|pat| { + if *pat == "*" { + return true; + } + glob_match(pat, file_name.as_ref()) || glob_match(pat, rel_str.as_ref()) + }); + let excluded = exclude_patterns.iter().any(|pat| { + glob_match(pat, file_name.as_ref()) || glob_match(pat, rel_str.as_ref()) + }); + included && !excluded + }) + .collect() +} + +fn glob_match(pattern: &str, name: &str) -> bool { + // Simple glob: splits on `*` and checks that each segment appears in order. + // Handles `*.ext`, `prefix*`, `dir/*.yml`, etc. + let parts: Vec<&str> = pattern.splitn(2, '*').collect(); + match parts.as_slice() { + [only] => name == *only || name.ends_with(&format!("/{only}")), + [prefix, suffix] => { + let n = name; + // The prefix must match the start of the name (or the part after the last slash). + let anchor_start = prefix.is_empty() || n.starts_with(prefix) || { + // Allow matching the basename portion for patterns like `*.sh`. + n.contains('/') && { + let after_slash = n.rfind('/').map(|i| &n[i + 1..]).unwrap_or(n); + prefix.is_empty() || after_slash.starts_with(prefix) + } + }; + anchor_start && n.ends_with(suffix) + } + _ => false, + } +} + +fn substitute_merge_base(cmd: &str, merge_base: Option<&str>) -> String { + if let Some(base) = merge_base { + cmd.replace("{MERGE_BASE}", base) + } else { + // Strip any flag containing {MERGE_BASE} (e.g. --new-from-rev={MERGE_BASE}). + cmd.split_whitespace() + .filter(|tok| !tok.contains("{MERGE_BASE}")) + .collect::>() + .join(" ") + } +} + +fn quote_path(p: &Path) -> String { + let s = p.to_string_lossy(); + format!("\"{}\"", s.replace('"', "\\\"")) +} + +fn shell_words(cmd: String) -> Vec { + // Minimal word-splitting that respects single- and double-quoted strings. + let mut words = vec![]; + let mut current = String::new(); + let mut in_single = false; + let mut in_double = false; + let chars: Vec = cmd.chars().collect(); + let mut i = 0; + while i < chars.len() { + match chars[i] { + '\'' if !in_single && !in_double => { + in_single = true; + } + '\'' if in_single => { + in_single = false; + } + '"' if !in_single && !in_double => { + in_double = true; + } + '"' if in_double => { + in_double = false; + } + '\\' if in_double => { + // Only handle \" inside double quotes; pass other backslashes through. + if i + 1 < chars.len() && chars[i + 1] == '"' { + current.push('"'); + i += 2; + continue; + } + current.push('\\'); + } + ' ' | '\t' if !in_single && !in_double => { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + } + c => current.push(c), + } + i += 1; + } + if !current.is_empty() { + words.push(current); + } + words +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::files::FileList; + use crate::registry::{Category, Check, CheckKind, Scope}; + use std::path::PathBuf; + + #[test] + fn inject_config_inserts_after_binary() { + let argv = vec!["shellcheck".to_string(), "file.sh".to_string()]; + let config = vec!["--rcfile".to_string(), "/cfg/.shellcheckrc".to_string()]; + assert_eq!( + inject_config(argv, &config), + vec!["shellcheck", "--rcfile", "/cfg/.shellcheckrc", "file.sh"], + ); + } + + #[test] + fn inject_config_noop_when_no_config_args() { + let argv = vec!["shellcheck".to_string(), "file.sh".to_string()]; + assert_eq!(inject_config(argv.clone(), &[]), argv,); + } + + #[test] + fn inject_config_noop_when_argv_empty() { + assert_eq!( + inject_config(vec![], &["--rcfile".to_string()]), + vec![] as Vec + ); + } + + #[test] + fn resolve_linter_config_absent_file_returns_empty() { + let check = Check::file("shellcheck", "shellcheck {FILE}", &["*.sh"]) + .linter_config(".shellcheckrc", "--rcfile"); + let dir = tempfile::tempdir().unwrap(); + assert!(resolve_linter_config(&check, dir.path()).is_empty()); + } + + #[test] + fn resolve_linter_config_present_file_returns_flag_and_path() { + let check = Check::file("shellcheck", "shellcheck {FILE}", &["*.sh"]) + .linter_config(".shellcheckrc", "--rcfile"); + let dir = tempfile::tempdir().unwrap(); + let cfg_path = dir.path().join(".shellcheckrc"); + std::fs::write(&cfg_path, "").unwrap(); + let result = resolve_linter_config(&check, dir.path()); + assert_eq!( + result, + vec!["--rcfile", cfg_path.to_string_lossy().as_ref()] + ); + } + + #[test] + fn resolve_linter_config_none_returns_empty() { + let check = Check::file("shellcheck", "shellcheck {FILE}", &["*.sh"]); + let dir = tempfile::tempdir().unwrap(); + assert!(resolve_linter_config(&check, dir.path()).is_empty()); + } + + fn project_check(patterns: &'static [&'static str]) -> Check { + Check { + name: "test", + bin_name: "test-bin", + mise_tool_name: None, + version_range: None, + patterns, + excludes_if_active: &[], + linter_config: None, + is_formatter: false, + defers_to_formatters: false, + activate_unconditionally: false, + category: Category::Default, + mise_install_components: None, + kind: CheckKind::Template { + check_cmd: "run-it", + fix_cmd: "", + full_cmd: "", + full_fix_cmd: "", + scope: Scope::Project, + }, + versioned_bin_fmt: None, + desc: "", + docs: "", + } + } + + fn file_list(paths: &[&str]) -> FileList { + FileList { + files: paths + .iter() + .map(|s| PathBuf::from(format!("/repo/{s}"))) + .collect(), + merge_base: Some("abc123".to_string()), + full: false, + } + } + + #[test] + fn project_scope_skips_when_no_matching_files() { + let check = project_check(&["*.rs"]); + let fl = file_list(&["foo.py", "bar.md"]); + assert!( + build_invocations( + &check, + &fl, + false, + Path::new("/repo"), + &[], + Path::new("/repo"), + &Default::default(), + ) + .is_empty() + ); + } + + #[test] + fn project_scope_runs_when_matching_files_present() { + let check = project_check(&["*.rs"]); + let fl = file_list(&["src/main.rs", "foo.py"]); + let inv = build_invocations( + &check, + &fl, + false, + Path::new("/repo"), + &[], + Path::new("/repo"), + &Default::default(), + ); + assert_eq!(inv, vec![vec!["run-it".to_string()]]); + } + + #[test] + fn project_scope_empty_patterns_always_runs() { + let check = project_check(&[]); + let fl = file_list(&["foo.py"]); + let inv = build_invocations( + &check, + &fl, + false, + Path::new("/repo"), + &[], + Path::new("/repo"), + &Default::default(), + ); + assert_eq!(inv, vec![vec!["run-it".to_string()]]); + } + + #[test] + fn appends_rust_component_note_for_missing_clippy() { + let mut stderr = b"error: 'cargo-clippy' is not installed for the toolchain '1.94.1-x86_64-unknown-linux-gnu'.\n".to_vec(); + + maybe_append_rust_component_note("cargo-clippy", &mut stderr); + + let msg = String::from_utf8(stderr).unwrap(); + assert!(msg.contains("NOTE: `cargo-clippy` needs the Rust `clippy` component")); + assert!(msg.contains("rustup component add clippy")); + } + + #[test] + fn appends_rust_component_note_for_missing_rustfmt() { + let mut stderr = + b"error: 'rustfmt' is not installed for the toolchain '1.94.1-x86_64-unknown-linux-gnu'.\n".to_vec(); + + maybe_append_rust_component_note("cargo-fmt", &mut stderr); + + let msg = String::from_utf8(stderr).unwrap(); + assert!(msg.contains("NOTE: `cargo-fmt` needs the Rust `rustfmt` component")); + assert!(msg.contains("rustup component add rustfmt")); + } +} diff --git a/tests/cases/actionlint/clean/files/.github/workflows/ci.yml b/tests/cases/actionlint/clean/files/.github/workflows/ci.yml new file mode 100644 index 0000000..975ef76 --- /dev/null +++ b/tests/cases/actionlint/clean/files/.github/workflows/ci.yml @@ -0,0 +1,8 @@ +name: CI +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: echo "hello" diff --git a/tests/cases/actionlint/clean/files/mise.toml b/tests/cases/actionlint/clean/files/mise.toml new file mode 100644 index 0000000..fd57bb2 --- /dev/null +++ b/tests/cases/actionlint/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +actionlint = "latest" diff --git a/tests/cases/actionlint/clean/test.toml b/tests/cases/actionlint/clean/test.toml new file mode 100644 index 0000000..c454ffb --- /dev/null +++ b/tests/cases/actionlint/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full actionlint" +exit = 0 diff --git a/tests/cases/actionlint/failure/files/.github/workflows/ci.yml b/tests/cases/actionlint/failure/files/.github/workflows/ci.yml new file mode 100644 index 0000000..c3e5b7d --- /dev/null +++ b/tests/cases/actionlint/failure/files/.github/workflows/ci.yml @@ -0,0 +1,9 @@ +name: CI +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + invalid_input: foo diff --git a/tests/cases/actionlint/failure/files/mise.toml b/tests/cases/actionlint/failure/files/mise.toml new file mode 100644 index 0000000..fd57bb2 --- /dev/null +++ b/tests/cases/actionlint/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +actionlint = "latest" diff --git a/tests/cases/actionlint/failure/test.toml b/tests/cases/actionlint/failure/test.toml new file mode 100644 index 0000000..ea40b4e --- /dev/null +++ b/tests/cases/actionlint/failure/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full actionlint" +exit = 1 +stderr = ''' +[actionlint] +.github/workflows/ci.yml:9:11: input "invalid_input" is not defined in action "actions/checkout@v4". available inputs are "clean", "fetch-depth", "fetch-tags", "filter", "github-server-url", "lfs", "path", "persist-credentials", "ref", "repository", "set-safe-directory", "show-progress", "sparse-checkout", "sparse-checkout-cone-mode", "ssh-key", "ssh-known-hosts", "ssh-strict", "ssh-user", "submodules", "token" [action] + | +9 | invalid_input: foo + | ^~~~~~~~~~~~~~ + +flint: 1 check failed (actionlint) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/biome-format/auto-fix/files/data.json b/tests/cases/biome-format/auto-fix/files/data.json new file mode 100644 index 0000000..8716584 --- /dev/null +++ b/tests/cases/biome-format/auto-fix/files/data.json @@ -0,0 +1 @@ +{"foo":"bar","baz":1} diff --git a/tests/cases/biome-format/auto-fix/files/mise.toml b/tests/cases/biome-format/auto-fix/files/mise.toml new file mode 100644 index 0000000..e6f9697 --- /dev/null +++ b/tests/cases/biome-format/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +biome = "latest" diff --git a/tests/cases/biome-format/auto-fix/test.toml b/tests/cases/biome-format/auto-fix/test.toml new file mode 100644 index 0000000..b88f8ab --- /dev/null +++ b/tests/cases/biome-format/auto-fix/test.toml @@ -0,0 +1,11 @@ +[expected] +args = "run --full --fix biome-format" +exit = 1 +stderr = ''' +flint: fixed: biome-format — commit before pushing +''' + +[expected.files] +"data.json" = """ +{ "foo": "bar", "baz": 1 } +""" \ No newline at end of file diff --git a/tests/cases/biome-format/clean/files/data.json b/tests/cases/biome-format/clean/files/data.json new file mode 100644 index 0000000..dd272dd --- /dev/null +++ b/tests/cases/biome-format/clean/files/data.json @@ -0,0 +1 @@ +{ "foo": "bar", "baz": 1 } diff --git a/tests/cases/biome-format/clean/files/mise.toml b/tests/cases/biome-format/clean/files/mise.toml new file mode 100644 index 0000000..e6f9697 --- /dev/null +++ b/tests/cases/biome-format/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +biome = "latest" diff --git a/tests/cases/biome-format/clean/test.toml b/tests/cases/biome-format/clean/test.toml new file mode 100644 index 0000000..ab40b20 --- /dev/null +++ b/tests/cases/biome-format/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full biome-format" +exit = 0 diff --git a/tests/cases/biome-format/failure/files/data.json b/tests/cases/biome-format/failure/files/data.json new file mode 100644 index 0000000..8716584 --- /dev/null +++ b/tests/cases/biome-format/failure/files/data.json @@ -0,0 +1 @@ +{"foo":"bar","baz":1} diff --git a/tests/cases/biome-format/failure/files/mise.toml b/tests/cases/biome-format/failure/files/mise.toml new file mode 100644 index 0000000..e6f9697 --- /dev/null +++ b/tests/cases/biome-format/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +biome = "latest" diff --git a/tests/cases/biome-format/failure/test.toml b/tests/cases/biome-format/failure/test.toml new file mode 100644 index 0000000..173ac4d --- /dev/null +++ b/tests/cases/biome-format/failure/test.toml @@ -0,0 +1,23 @@ +[expected] +args = "run --full biome-format" +exit = 1 +stderr = ''' +[biome-format] +Checked N file(s) in Xµs. No fixes applied. +Found 1 error. +data.json format ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Formatter would have printed the following content: + + 1 │ {·"foo":·"bar",·"baz":·1·} + │ + + + + + + +format ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Some errors were emitted while running checks. + + + +flint: 1 check failed (biome-format) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/biome/clean/files/main.js b/tests/cases/biome/clean/files/main.js new file mode 100644 index 0000000..702f428 --- /dev/null +++ b/tests/cases/biome/clean/files/main.js @@ -0,0 +1 @@ +console.log("hello"); diff --git a/tests/cases/biome/clean/files/mise.toml b/tests/cases/biome/clean/files/mise.toml new file mode 100644 index 0000000..e6f9697 --- /dev/null +++ b/tests/cases/biome/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +biome = "latest" diff --git a/tests/cases/biome/clean/test.toml b/tests/cases/biome/clean/test.toml new file mode 100644 index 0000000..29db2e1 --- /dev/null +++ b/tests/cases/biome/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full biome" +exit = 0 diff --git a/tests/cases/biome/failure/files/main.js b/tests/cases/biome/failure/files/main.js new file mode 100644 index 0000000..1a238c2 --- /dev/null +++ b/tests/cases/biome/failure/files/main.js @@ -0,0 +1,2 @@ +debugger; +console.log("hello"); diff --git a/tests/cases/biome/failure/files/mise.toml b/tests/cases/biome/failure/files/mise.toml new file mode 100644 index 0000000..e6f9697 --- /dev/null +++ b/tests/cases/biome/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +biome = "latest" diff --git a/tests/cases/biome/failure/test.toml b/tests/cases/biome/failure/test.toml new file mode 100644 index 0000000..8857113 --- /dev/null +++ b/tests/cases/biome/failure/test.toml @@ -0,0 +1,30 @@ +[expected] +args = "run --full biome" +exit = 1 +stderr = ''' +[biome] +Checked N file(s) in Xµs. No fixes applied. +Found 1 error. +main.js:1:1 lint/suspicious/noDebugger FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × This is an unexpected use of the debugger statement. + + > 1 │ debugger; + │ ^^^^^^^^^ + 2 │ console.log("hello"); + 3 │ + + i Unsafe fix: Remove debugger statement + + 1 │ debugger; + │ --------- + +check ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Some errors were emitted while running checks. + + + +flint: 1 check failed (biome) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/cargo-clippy/auto-fix/files/Cargo.toml b/tests/cases/cargo-clippy/auto-fix/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-clippy/auto-fix/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-clippy/auto-fix/files/mise.toml b/tests/cases/cargo-clippy/auto-fix/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-clippy/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-clippy/auto-fix/files/src/lib.rs b/tests/cases/cargo-clippy/auto-fix/files/src/lib.rs new file mode 100644 index 0000000..920280b --- /dev/null +++ b/tests/cases/cargo-clippy/auto-fix/files/src/lib.rs @@ -0,0 +1,3 @@ +pub fn add(a: i32, b: i32) -> i32 { + return a + b; +} diff --git a/tests/cases/cargo-clippy/auto-fix/test.toml b/tests/cases/cargo-clippy/auto-fix/test.toml new file mode 100644 index 0000000..f6a0b07 --- /dev/null +++ b/tests/cases/cargo-clippy/auto-fix/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full --fix cargo-clippy" +exit = 1 +stderr = ''' +flint: fixed: cargo-clippy — commit before pushing +''' + +[expected.files] +"src/lib.rs" = """ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} +""" \ No newline at end of file diff --git a/tests/cases/cargo-clippy/clean/files/Cargo.toml b/tests/cases/cargo-clippy/clean/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-clippy/clean/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-clippy/clean/files/mise.toml b/tests/cases/cargo-clippy/clean/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-clippy/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-clippy/clean/files/src/lib.rs b/tests/cases/cargo-clippy/clean/files/src/lib.rs new file mode 100644 index 0000000..b4a2a9e --- /dev/null +++ b/tests/cases/cargo-clippy/clean/files/src/lib.rs @@ -0,0 +1,3 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} diff --git a/tests/cases/cargo-clippy/clean/test.toml b/tests/cases/cargo-clippy/clean/test.toml new file mode 100644 index 0000000..4c41c93 --- /dev/null +++ b/tests/cases/cargo-clippy/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full cargo-clippy" +exit = 0 diff --git a/tests/cases/cargo-clippy/failure/files/Cargo.toml b/tests/cases/cargo-clippy/failure/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-clippy/failure/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-clippy/failure/files/mise.toml b/tests/cases/cargo-clippy/failure/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-clippy/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-clippy/failure/files/src/lib.rs b/tests/cases/cargo-clippy/failure/files/src/lib.rs new file mode 100644 index 0000000..785a01f --- /dev/null +++ b/tests/cases/cargo-clippy/failure/files/src/lib.rs @@ -0,0 +1,3 @@ +pub fn tautology(x: i32) -> bool { + x == x +} diff --git a/tests/cases/cargo-clippy/failure/test.toml b/tests/cases/cargo-clippy/failure/test.toml new file mode 100644 index 0000000..52cfbb7 --- /dev/null +++ b/tests/cases/cargo-clippy/failure/test.toml @@ -0,0 +1,19 @@ +[expected] +args = "run --full cargo-clippy" +exit = 1 +stderr = ''' +[cargo-clippy] +error: equal expressions as operands to `==` + --> src/lib.rs:2:5 + | +2 | x == x + | ^^^^^^ + | + = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.94.0/index.html#eq_op + = note: `#[deny(clippy::eq_op)]` on by default + +error: could not compile `test` (lib) due to 1 previous error + +flint: 1 check failed (cargo-clippy) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/cargo-fmt/auto-fix/files/Cargo.toml b/tests/cases/cargo-fmt/auto-fix/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-fmt/auto-fix/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-fmt/auto-fix/files/mise.toml b/tests/cases/cargo-fmt/auto-fix/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-fmt/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-fmt/auto-fix/files/src/lib.rs b/tests/cases/cargo-fmt/auto-fix/files/src/lib.rs new file mode 100644 index 0000000..1379407 --- /dev/null +++ b/tests/cases/cargo-fmt/auto-fix/files/src/lib.rs @@ -0,0 +1 @@ +pub struct Foo { pub a: u32, pub b: u32 } diff --git a/tests/cases/cargo-fmt/auto-fix/test.toml b/tests/cases/cargo-fmt/auto-fix/test.toml new file mode 100644 index 0000000..2369660 --- /dev/null +++ b/tests/cases/cargo-fmt/auto-fix/test.toml @@ -0,0 +1,14 @@ +[expected] +args = "run --full --fix cargo-fmt" +exit = 1 +stderr = ''' +flint: fixed: cargo-fmt — commit before pushing +''' + +[expected.files] +"src/lib.rs" = """ +pub struct Foo { + pub a: u32, + pub b: u32, +} +""" \ No newline at end of file diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/main.rs b/tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/main.rs new file mode 100644 index 0000000..5510b5f --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/main.rs @@ -0,0 +1,2 @@ +mod math; +fn main() { math::add(1, 2); } diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/math.rs b/tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/math.rs new file mode 100644 index 0000000..a051497 --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/changes/src/math.rs @@ -0,0 +1 @@ +pub fn add(a: i32, b: i32) -> i32 { a + b } diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/files/Cargo.toml b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/files/mise.toml b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/main.rs b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/main.rs new file mode 100644 index 0000000..99dd7a7 --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/main.rs @@ -0,0 +1,2 @@ +mod math; +fn main() {} diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/math.rs b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/math.rs new file mode 100644 index 0000000..b4a2a9e --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/files/src/math.rs @@ -0,0 +1,3 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} diff --git a/tests/cases/cargo-fmt/changed-files-no-duplicate/test.toml b/tests/cases/cargo-fmt/changed-files-no-duplicate/test.toml new file mode 100644 index 0000000..6bc975f --- /dev/null +++ b/tests/cases/cargo-fmt/changed-files-no-duplicate/test.toml @@ -0,0 +1,25 @@ +# Regression: with multiple changed .rs files (including a module root), each +# formatting issue must appear exactly once. The old per-file rustfmt approach +# would output math.rs twice: once via `mod math` in main.rs, once directly. +[expected] +args = "run cargo-fmt" +exit = 1 +stderr = ''' +[cargo-fmt] +Diff in /src/main.rs:1: + mod math; +-fn main() { math::add(1, 2); } ++fn main() { ++ math::add(1, 2); ++} + +Diff in /src/math.rs:1: +-pub fn add(a: i32, b: i32) -> i32 { a + b } ++pub fn add(a: i32, b: i32) -> i32 { ++ a + b ++} + + +flint: 1 check failed (cargo-fmt) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/cargo-fmt/clean/files/Cargo.toml b/tests/cases/cargo-fmt/clean/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-fmt/clean/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-fmt/clean/files/mise.toml b/tests/cases/cargo-fmt/clean/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-fmt/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-fmt/clean/files/src/lib.rs b/tests/cases/cargo-fmt/clean/files/src/lib.rs new file mode 100644 index 0000000..b4a2a9e --- /dev/null +++ b/tests/cases/cargo-fmt/clean/files/src/lib.rs @@ -0,0 +1,3 @@ +pub fn add(a: i32, b: i32) -> i32 { + a + b +} diff --git a/tests/cases/cargo-fmt/clean/test.toml b/tests/cases/cargo-fmt/clean/test.toml new file mode 100644 index 0000000..a92a3c2 --- /dev/null +++ b/tests/cases/cargo-fmt/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full cargo-fmt" +exit = 0 diff --git a/tests/cases/cargo-fmt/failure/files/Cargo.toml b/tests/cases/cargo-fmt/failure/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/cargo-fmt/failure/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/cargo-fmt/failure/files/mise.toml b/tests/cases/cargo-fmt/failure/files/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/tests/cases/cargo-fmt/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/tests/cases/cargo-fmt/failure/files/src/lib.rs b/tests/cases/cargo-fmt/failure/files/src/lib.rs new file mode 100644 index 0000000..1379407 --- /dev/null +++ b/tests/cases/cargo-fmt/failure/files/src/lib.rs @@ -0,0 +1 @@ +pub struct Foo { pub a: u32, pub b: u32 } diff --git a/tests/cases/cargo-fmt/failure/test.toml b/tests/cases/cargo-fmt/failure/test.toml new file mode 100644 index 0000000..6455e5d --- /dev/null +++ b/tests/cases/cargo-fmt/failure/test.toml @@ -0,0 +1,16 @@ +[expected] +args = "run --full cargo-fmt" +exit = 1 +stderr = ''' +[cargo-fmt] +Diff in /src/lib.rs:1: +-pub struct Foo { pub a: u32, pub b: u32 } ++pub struct Foo { ++ pub a: u32, ++ pub b: u32, ++} + + +flint: 1 check failed (cargo-fmt) +💡 Try `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/codespell/clean/files/README.md b/tests/cases/codespell/clean/files/README.md new file mode 100644 index 0000000..8e9a457 --- /dev/null +++ b/tests/cases/codespell/clean/files/README.md @@ -0,0 +1,3 @@ +# Project + +This is the documentation for the project. diff --git a/tests/cases/codespell/clean/files/mise.toml b/tests/cases/codespell/clean/files/mise.toml new file mode 100644 index 0000000..06f01dc --- /dev/null +++ b/tests/cases/codespell/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +codespell = "latest" diff --git a/tests/cases/codespell/clean/test.toml b/tests/cases/codespell/clean/test.toml new file mode 100644 index 0000000..b4f3585 --- /dev/null +++ b/tests/cases/codespell/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full codespell" +exit = 0 diff --git a/tests/cases/codespell/failure/files/README.md b/tests/cases/codespell/failure/files/README.md new file mode 100644 index 0000000..7d1092d --- /dev/null +++ b/tests/cases/codespell/failure/files/README.md @@ -0,0 +1,3 @@ +# Project + +This is teh documentation for teh project. diff --git a/tests/cases/codespell/failure/files/mise.toml b/tests/cases/codespell/failure/files/mise.toml new file mode 100644 index 0000000..06f01dc --- /dev/null +++ b/tests/cases/codespell/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +codespell = "latest" diff --git a/tests/cases/codespell/failure/test.toml b/tests/cases/codespell/failure/test.toml new file mode 100644 index 0000000..7be19bf --- /dev/null +++ b/tests/cases/codespell/failure/test.toml @@ -0,0 +1,11 @@ +[expected] +args = "run --full codespell" +exit = 1 +stderr = ''' +[codespell] +/README.md:3: teh ==> the +/README.md:3: teh ==> the + +flint: 1 check failed (codespell) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/dotnet-format/auto-fix/files/.editorconfig b/tests/cases/dotnet-format/auto-fix/files/.editorconfig new file mode 100644 index 0000000..a9955e6 --- /dev/null +++ b/tests/cases/dotnet-format/auto-fix/files/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*.cs] +end_of_line = lf +indent_style = space +indent_size = 4 diff --git a/tests/cases/dotnet-format/auto-fix/files/App.csproj b/tests/cases/dotnet-format/auto-fix/files/App.csproj new file mode 100644 index 0000000..555ae43 --- /dev/null +++ b/tests/cases/dotnet-format/auto-fix/files/App.csproj @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/tests/cases/dotnet-format/auto-fix/files/Program.cs b/tests/cases/dotnet-format/auto-fix/files/Program.cs new file mode 100644 index 0000000..fe1caf2 --- /dev/null +++ b/tests/cases/dotnet-format/auto-fix/files/Program.cs @@ -0,0 +1,7 @@ +class Program +{ +static void Main() +{ +System.Console.WriteLine("Hello"); +} +} diff --git a/tests/cases/dotnet-format/auto-fix/files/mise.toml b/tests/cases/dotnet-format/auto-fix/files/mise.toml new file mode 100644 index 0000000..e95ecd7 --- /dev/null +++ b/tests/cases/dotnet-format/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +dotnet = "latest" diff --git a/tests/cases/dotnet-format/auto-fix/test.toml b/tests/cases/dotnet-format/auto-fix/test.toml new file mode 100644 index 0000000..1114a92 --- /dev/null +++ b/tests/cases/dotnet-format/auto-fix/test.toml @@ -0,0 +1,21 @@ +[expected] +args = "run --full --fix dotnet-format" +exit = 1 +stderr = ''' +flint: fixed: dotnet-format — commit before pushing +''' + +[expected.files] +"Program.cs" = """ +class Program +{ + static void Main() + { + System.Console.WriteLine("Hello"); + } +} +""" + +[env] +DOTNET_CLI_HOME = "/tmp/dotnet-home" +DOTNET_NOLOGO = "1" diff --git a/tests/cases/dotnet-format/clean/files/.editorconfig b/tests/cases/dotnet-format/clean/files/.editorconfig new file mode 100644 index 0000000..a9955e6 --- /dev/null +++ b/tests/cases/dotnet-format/clean/files/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*.cs] +end_of_line = lf +indent_style = space +indent_size = 4 diff --git a/tests/cases/dotnet-format/clean/files/App.csproj b/tests/cases/dotnet-format/clean/files/App.csproj new file mode 100644 index 0000000..555ae43 --- /dev/null +++ b/tests/cases/dotnet-format/clean/files/App.csproj @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/tests/cases/dotnet-format/clean/files/Program.cs b/tests/cases/dotnet-format/clean/files/Program.cs new file mode 100644 index 0000000..e913fd1 --- /dev/null +++ b/tests/cases/dotnet-format/clean/files/Program.cs @@ -0,0 +1,7 @@ +class Program +{ + static void Main() + { + System.Console.WriteLine("Hello"); + } +} diff --git a/tests/cases/dotnet-format/clean/files/mise.toml b/tests/cases/dotnet-format/clean/files/mise.toml new file mode 100644 index 0000000..e95ecd7 --- /dev/null +++ b/tests/cases/dotnet-format/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +dotnet = "latest" diff --git a/tests/cases/dotnet-format/clean/test.toml b/tests/cases/dotnet-format/clean/test.toml new file mode 100644 index 0000000..430b04d --- /dev/null +++ b/tests/cases/dotnet-format/clean/test.toml @@ -0,0 +1,8 @@ +[expected] +args = "run --full dotnet-format" +exit = 0 + + +[env] +DOTNET_CLI_HOME = "/tmp/dotnet-home" +DOTNET_NOLOGO = "1" diff --git a/tests/cases/dotnet-format/failure/files/.editorconfig b/tests/cases/dotnet-format/failure/files/.editorconfig new file mode 100644 index 0000000..a9955e6 --- /dev/null +++ b/tests/cases/dotnet-format/failure/files/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*.cs] +end_of_line = lf +indent_style = space +indent_size = 4 diff --git a/tests/cases/dotnet-format/failure/files/App.csproj b/tests/cases/dotnet-format/failure/files/App.csproj new file mode 100644 index 0000000..555ae43 --- /dev/null +++ b/tests/cases/dotnet-format/failure/files/App.csproj @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/tests/cases/dotnet-format/failure/files/Program.cs b/tests/cases/dotnet-format/failure/files/Program.cs new file mode 100644 index 0000000..fe1caf2 --- /dev/null +++ b/tests/cases/dotnet-format/failure/files/Program.cs @@ -0,0 +1,7 @@ +class Program +{ +static void Main() +{ +System.Console.WriteLine("Hello"); +} +} diff --git a/tests/cases/dotnet-format/failure/files/mise.toml b/tests/cases/dotnet-format/failure/files/mise.toml new file mode 100644 index 0000000..e95ecd7 --- /dev/null +++ b/tests/cases/dotnet-format/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +dotnet = "latest" diff --git a/tests/cases/dotnet-format/failure/test.toml b/tests/cases/dotnet-format/failure/test.toml new file mode 100644 index 0000000..c1961bb --- /dev/null +++ b/tests/cases/dotnet-format/failure/test.toml @@ -0,0 +1,17 @@ +[expected] +args = "run --full dotnet-format" +exit = 1 +stderr = ''' +[dotnet-format] +/Program.cs(3,1): error WHITESPACE: Fix whitespace formatting. Insert '\s\s\s\s'. [/App.csproj] +/Program.cs(4,1): error WHITESPACE: Fix whitespace formatting. Insert '\s\s\s\s'. [/App.csproj] +/Program.cs(5,1): error WHITESPACE: Fix whitespace formatting. Insert '\s\s\s\s\s\s\s\s'. [/App.csproj] +/Program.cs(6,1): error WHITESPACE: Fix whitespace formatting. Insert '\s\s\s\s'. [/App.csproj] + +flint: 1 check failed (dotnet-format) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' + +[env] +DOTNET_CLI_HOME = "/tmp/dotnet-home" +DOTNET_NOLOGO = "1" diff --git a/tests/cases/editorconfig-checker/clean/files/.editorconfig b/tests/cases/editorconfig-checker/clean/files/.editorconfig new file mode 100644 index 0000000..9da00de --- /dev/null +++ b/tests/cases/editorconfig-checker/clean/files/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/tests/cases/editorconfig-checker/clean/files/hello.txt b/tests/cases/editorconfig-checker/clean/files/hello.txt new file mode 100644 index 0000000..94954ab --- /dev/null +++ b/tests/cases/editorconfig-checker/clean/files/hello.txt @@ -0,0 +1,2 @@ +hello +world diff --git a/tests/cases/editorconfig-checker/clean/files/mise.toml b/tests/cases/editorconfig-checker/clean/files/mise.toml new file mode 100644 index 0000000..972b254 --- /dev/null +++ b/tests/cases/editorconfig-checker/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +editorconfig-checker = "latest" diff --git a/tests/cases/editorconfig-checker/clean/test.toml b/tests/cases/editorconfig-checker/clean/test.toml new file mode 100644 index 0000000..a70bf54 --- /dev/null +++ b/tests/cases/editorconfig-checker/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full editorconfig-checker" +exit = 0 diff --git a/tests/cases/editorconfig-checker/failure/files/.editorconfig b/tests/cases/editorconfig-checker/failure/files/.editorconfig new file mode 100644 index 0000000..9da00de --- /dev/null +++ b/tests/cases/editorconfig-checker/failure/files/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/tests/cases/editorconfig-checker/failure/files/hello.txt b/tests/cases/editorconfig-checker/failure/files/hello.txt new file mode 100644 index 0000000..5d1cd3a --- /dev/null +++ b/tests/cases/editorconfig-checker/failure/files/hello.txt @@ -0,0 +1,2 @@ +hello +world diff --git a/tests/cases/editorconfig-checker/failure/files/mise.toml b/tests/cases/editorconfig-checker/failure/files/mise.toml new file mode 100644 index 0000000..972b254 --- /dev/null +++ b/tests/cases/editorconfig-checker/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +editorconfig-checker = "latest" diff --git a/tests/cases/editorconfig-checker/failure/test.toml b/tests/cases/editorconfig-checker/failure/test.toml new file mode 100644 index 0000000..e7ce1be --- /dev/null +++ b/tests/cases/editorconfig-checker/failure/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full editorconfig-checker" +exit = 1 +stderr = ''' +[editorconfig-checker] +hello.txt: + 2: Trailing whitespace + +1 errors found + +flint: 1 check failed (editorconfig-checker) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/editorconfig-checker/formatter-exclusion/files/.editorconfig b/tests/cases/editorconfig-checker/formatter-exclusion/files/.editorconfig new file mode 100644 index 0000000..9da00de --- /dev/null +++ b/tests/cases/editorconfig-checker/formatter-exclusion/files/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/tests/cases/editorconfig-checker/formatter-exclusion/files/mise.toml b/tests/cases/editorconfig-checker/formatter-exclusion/files/mise.toml new file mode 100644 index 0000000..c8a7cc2 --- /dev/null +++ b/tests/cases/editorconfig-checker/formatter-exclusion/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +editorconfig-checker = "latest" +"github:mvdan/sh" = "v3.12.0" diff --git a/tests/cases/editorconfig-checker/formatter-exclusion/files/script.sh b/tests/cases/editorconfig-checker/formatter-exclusion/files/script.sh new file mode 100644 index 0000000..e66e97f --- /dev/null +++ b/tests/cases/editorconfig-checker/formatter-exclusion/files/script.sh @@ -0,0 +1,4 @@ +#!/bin/sh +if true; then + echo "hello" +fi diff --git a/tests/cases/editorconfig-checker/formatter-exclusion/test.toml b/tests/cases/editorconfig-checker/formatter-exclusion/test.toml new file mode 100644 index 0000000..04e6e4f --- /dev/null +++ b/tests/cases/editorconfig-checker/formatter-exclusion/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full" +exit = 0 diff --git a/tests/cases/general/auto-fix-and-review/files/Cargo.toml b/tests/cases/general/auto-fix-and-review/files/Cargo.toml new file mode 100644 index 0000000..2c80963 --- /dev/null +++ b/tests/cases/general/auto-fix-and-review/files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" diff --git a/tests/cases/general/auto-fix-and-review/files/bad.sh b/tests/cases/general/auto-fix-and-review/files/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/general/auto-fix-and-review/files/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/general/auto-fix-and-review/files/mise.toml b/tests/cases/general/auto-fix-and-review/files/mise.toml new file mode 100644 index 0000000..8ecc60a --- /dev/null +++ b/tests/cases/general/auto-fix-and-review/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +rust = "latest" +shellcheck = "latest" diff --git a/tests/cases/general/auto-fix-and-review/files/src/lib.rs b/tests/cases/general/auto-fix-and-review/files/src/lib.rs new file mode 100644 index 0000000..1379407 --- /dev/null +++ b/tests/cases/general/auto-fix-and-review/files/src/lib.rs @@ -0,0 +1 @@ +pub struct Foo { pub a: u32, pub b: u32 } diff --git a/tests/cases/general/auto-fix-and-review/test.toml b/tests/cases/general/auto-fix-and-review/test.toml new file mode 100644 index 0000000..4397d34 --- /dev/null +++ b/tests/cases/general/auto-fix-and-review/test.toml @@ -0,0 +1,25 @@ +[expected] +args = "run --full --fix cargo-fmt shellcheck" +exit = 1 +stderr = ''' +[shellcheck] + +In /bad.sh line 2: +echo $1 + ^-- SC2086 (info): Double quote to prevent globbing and word splitting. + +Did you mean: +echo "$1" + +For more information: + https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... +flint: fixed: cargo-fmt — commit before pushing | review: shellcheck +''' + +[expected.files] +"src/lib.rs" = """ +pub struct Foo { + pub a: u32, + pub b: u32, +} +""" \ No newline at end of file diff --git a/tests/cases/general/auto-review-two-linters/files/.github/workflows/ci.yml b/tests/cases/general/auto-review-two-linters/files/.github/workflows/ci.yml new file mode 100644 index 0000000..36bed8f --- /dev/null +++ b/tests/cases/general/auto-review-two-linters/files/.github/workflows/ci.yml @@ -0,0 +1,6 @@ +on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo ${{ foo.bar }} diff --git a/tests/cases/general/auto-review-two-linters/files/bad.sh b/tests/cases/general/auto-review-two-linters/files/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/general/auto-review-two-linters/files/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/general/auto-review-two-linters/files/mise.toml b/tests/cases/general/auto-review-two-linters/files/mise.toml new file mode 100644 index 0000000..a27736f --- /dev/null +++ b/tests/cases/general/auto-review-two-linters/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +shellcheck = "latest" +actionlint = "latest" diff --git a/tests/cases/general/auto-review-two-linters/test.toml b/tests/cases/general/auto-review-two-linters/test.toml new file mode 100644 index 0000000..b7f166e --- /dev/null +++ b/tests/cases/general/auto-review-two-linters/test.toml @@ -0,0 +1,28 @@ +[expected] +args = "run --full --fix shellcheck actionlint" +exit = 1 +stderr = ''' +[actionlint] +.github/workflows/ci.yml:6:23: undefined variable "foo". available variables are "env", "github", "inputs", "job", "matrix", "needs", "runner", "secrets", "steps", "strategy", "vars" [expression] + | +6 | - run: echo ${{ foo.bar }} + | ^~~~~~~ +[shellcheck] + +In /bad.sh line 2: +echo $1 + ^-- SC2086 (info): Double quote to prevent globbing and word splitting. + +Did you mean: +echo "$1" + +For more information: + https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... +flint: review: actionlint, shellcheck +''' +[fake_bins] +actionlint = ''' +#!/bin/sh +printf '.github/workflows/ci.yml:6:23: undefined variable "foo". available variables are "env", "github", "inputs", "job", "matrix", "needs", "runner", "secrets", "steps", "strategy", "vars" [expression]\n |\n6 | - run: echo ${{ foo.bar }}\n | ^~~~~~~\n' +exit 1 +''' diff --git a/tests/cases/general/auto-review-unfixable/files/bad.sh b/tests/cases/general/auto-review-unfixable/files/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/general/auto-review-unfixable/files/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/general/auto-review-unfixable/files/mise.toml b/tests/cases/general/auto-review-unfixable/files/mise.toml new file mode 100644 index 0000000..d725983 --- /dev/null +++ b/tests/cases/general/auto-review-unfixable/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shellcheck = "latest" diff --git a/tests/cases/general/auto-review-unfixable/test.toml b/tests/cases/general/auto-review-unfixable/test.toml new file mode 100644 index 0000000..664c077 --- /dev/null +++ b/tests/cases/general/auto-review-unfixable/test.toml @@ -0,0 +1,17 @@ +[expected] +args = "run --full --fix shellcheck" +exit = 1 +stderr = ''' +[shellcheck] + +In /bad.sh line 2: +echo $1 + ^-- SC2086 (info): Double quote to prevent globbing and word splitting. + +Did you mean: +echo "$1" + +For more information: + https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... +flint: review: shellcheck +''' \ No newline at end of file diff --git a/tests/cases/general/env-var-exclude/files/bad.sh b/tests/cases/general/env-var-exclude/files/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/general/env-var-exclude/files/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/general/env-var-exclude/files/mise.toml b/tests/cases/general/env-var-exclude/files/mise.toml new file mode 100644 index 0000000..d725983 --- /dev/null +++ b/tests/cases/general/env-var-exclude/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shellcheck = "latest" diff --git a/tests/cases/general/env-var-exclude/test.toml b/tests/cases/general/env-var-exclude/test.toml new file mode 100644 index 0000000..d647a1e --- /dev/null +++ b/tests/cases/general/env-var-exclude/test.toml @@ -0,0 +1,7 @@ +[expected] +args = "run --full shellcheck" +exit = 0 + + +[env] +FLINT_EXCLUDE = '["bad.sh"]' diff --git a/tests/cases/general/exclude-paths/files/excluded/bad.sh b/tests/cases/general/exclude-paths/files/excluded/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/general/exclude-paths/files/excluded/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/general/exclude-paths/files/flint.toml b/tests/cases/general/exclude-paths/files/flint.toml new file mode 100644 index 0000000..0dc9a0d --- /dev/null +++ b/tests/cases/general/exclude-paths/files/flint.toml @@ -0,0 +1,2 @@ +[settings] +exclude = ["excluded/**"] diff --git a/tests/cases/general/exclude-paths/files/mise.toml b/tests/cases/general/exclude-paths/files/mise.toml new file mode 100644 index 0000000..d725983 --- /dev/null +++ b/tests/cases/general/exclude-paths/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shellcheck = "latest" diff --git a/tests/cases/general/exclude-paths/test.toml b/tests/cases/general/exclude-paths/test.toml new file mode 100644 index 0000000..0ce0ca5 --- /dev/null +++ b/tests/cases/general/exclude-paths/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full shellcheck" +exit = 0 diff --git a/tests/cases/general/fast-only-explicit-override/files/.github/renovate-tracked-deps.json b/tests/cases/general/fast-only-explicit-override/files/.github/renovate-tracked-deps.json new file mode 100644 index 0000000..b46b339 --- /dev/null +++ b/tests/cases/general/fast-only-explicit-override/files/.github/renovate-tracked-deps.json @@ -0,0 +1,8 @@ +{ + "package.json": { + "npm": [ + "express", + "lodash" + ] + } +} diff --git a/tests/cases/general/fast-only-explicit-override/files/.github/renovate.json5 b/tests/cases/general/fast-only-explicit-override/files/.github/renovate.json5 new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/general/fast-only-explicit-override/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/general/fast-only-explicit-override/files/mise.toml b/tests/cases/general/fast-only-explicit-override/files/mise.toml new file mode 100644 index 0000000..bd7b0a1 --- /dev/null +++ b/tests/cases/general/fast-only-explicit-override/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +"npm:renovate" = "latest" + diff --git a/tests/cases/general/fast-only-explicit-override/files/package.json b/tests/cases/general/fast-only-explicit-override/files/package.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/general/fast-only-explicit-override/files/package.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/general/fast-only-explicit-override/test.toml b/tests/cases/general/fast-only-explicit-override/test.toml new file mode 100644 index 0000000..9a27bc8 --- /dev/null +++ b/tests/cases/general/fast-only-explicit-override/test.toml @@ -0,0 +1,11 @@ +# --fast-only is overridden when linters are named explicitly. +# Naming a linter explicitly must run it regardless of --fast-only. +[expected] +args = "run --full --fast-only renovate-deps" +exit = 0 + +[fake_bins] +renovate = ''' +#!/bin/sh +printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' +''' diff --git a/tests/cases/general/list/files/mise.toml b/tests/cases/general/list/files/mise.toml new file mode 100644 index 0000000..005149a --- /dev/null +++ b/tests/cases/general/list/files/mise.toml @@ -0,0 +1,13 @@ +[tools] +lychee = "0.22.0" +"npm:renovate" = "43.92.1" +shellcheck = "v0.11.0" +shfmt = "v3.12.0" +actionlint = "1.7.10" +editorconfig-checker = "v3.6.1" +"npm:markdownlint-cli2" = "0.17.2" +"npm:prettier" = "3.8.1" +"npm:@biomejs/biome" = "2.3.14" +"pipx:ruff" = "0.15.0" +"pipx:codespell" = "2.4.1" +rust = { version = "1.94.1", components = "clippy,rustfmt" } diff --git a/tests/cases/general/list/test.toml b/tests/cases/general/list/test.toml new file mode 100644 index 0000000..e149c88 --- /dev/null +++ b/tests/cases/general/list/test.toml @@ -0,0 +1,70 @@ +[expected] +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 +''' +[fake_bins] +actionlint = ''' +#!/bin/sh +''' +biome = ''' +#!/bin/sh +''' +cargo-clippy = ''' +#!/bin/sh +''' +codespell = ''' +#!/bin/sh +''' +ec = ''' +#!/bin/sh +''' +lychee = ''' +#!/bin/sh +''' +markdownlint-cli2 = ''' +#!/bin/sh +''' +prettier = ''' +#!/bin/sh +''' +renovate = ''' +#!/bin/sh +''' +ruff = ''' +#!/bin/sh +''' +rustfmt = ''' +#!/bin/sh +''' +shellcheck = ''' +#!/bin/sh +''' +shfmt = ''' +#!/bin/sh +''' diff --git a/tests/cases/general/time-flag/files/.github/workflows/ci.yml b/tests/cases/general/time-flag/files/.github/workflows/ci.yml new file mode 100644 index 0000000..adecccf --- /dev/null +++ b/tests/cases/general/time-flag/files/.github/workflows/ci.yml @@ -0,0 +1,6 @@ +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hello diff --git a/tests/cases/general/time-flag/files/good.sh b/tests/cases/general/time-flag/files/good.sh new file mode 100644 index 0000000..ccc95ec --- /dev/null +++ b/tests/cases/general/time-flag/files/good.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo "$1" diff --git a/tests/cases/general/time-flag/files/mise.toml b/tests/cases/general/time-flag/files/mise.toml new file mode 100644 index 0000000..a27736f --- /dev/null +++ b/tests/cases/general/time-flag/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +shellcheck = "latest" +actionlint = "latest" diff --git a/tests/cases/general/time-flag/test.toml b/tests/cases/general/time-flag/test.toml new file mode 100644 index 0000000..f97c271 --- /dev/null +++ b/tests/cases/general/time-flag/test.toml @@ -0,0 +1,21 @@ +[expected] +args = "run --full --time shellcheck actionlint" +exit = 1 +stderr = ''' +[actionlint] Xms +workflow error: undefined variable +[shellcheck] Xms + +flint: 1 check failed (actionlint) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' +[fake_bins] +actionlint = ''' +#!/bin/sh +printf 'workflow error: undefined variable\n' +exit 1 +''' +shellcheck = ''' +#!/bin/sh +exit 0 +''' diff --git a/tests/cases/gofmt/auto-fix/files/main.go b/tests/cases/gofmt/auto-fix/files/main.go new file mode 100644 index 0000000..4e6c810 --- /dev/null +++ b/tests/cases/gofmt/auto-fix/files/main.go @@ -0,0 +1,5 @@ +package main + +func add(x int,y int) int { +return x+y +} diff --git a/tests/cases/gofmt/auto-fix/files/mise.toml b/tests/cases/gofmt/auto-fix/files/mise.toml new file mode 100644 index 0000000..b55f8c1 --- /dev/null +++ b/tests/cases/gofmt/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "latest" diff --git a/tests/cases/gofmt/auto-fix/test.toml b/tests/cases/gofmt/auto-fix/test.toml new file mode 100644 index 0000000..3cf634f --- /dev/null +++ b/tests/cases/gofmt/auto-fix/test.toml @@ -0,0 +1,15 @@ +[expected] +args = "run --full --fix gofmt" +exit = 1 +stderr = ''' +flint: fixed: gofmt — commit before pushing +''' + +[expected.files] +"main.go" = """ +package main + +func add(x int, y int) int { + return x + y +} +""" \ No newline at end of file diff --git a/tests/cases/gofmt/clean/files/main.go b/tests/cases/gofmt/clean/files/main.go new file mode 100644 index 0000000..4e5bf83 --- /dev/null +++ b/tests/cases/gofmt/clean/files/main.go @@ -0,0 +1,5 @@ +package main + +func add(x int, y int) int { + return x + y +} diff --git a/tests/cases/gofmt/clean/files/mise.toml b/tests/cases/gofmt/clean/files/mise.toml new file mode 100644 index 0000000..b55f8c1 --- /dev/null +++ b/tests/cases/gofmt/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "latest" diff --git a/tests/cases/gofmt/clean/test.toml b/tests/cases/gofmt/clean/test.toml new file mode 100644 index 0000000..05ec9ca --- /dev/null +++ b/tests/cases/gofmt/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full gofmt" +exit = 0 diff --git a/tests/cases/gofmt/failure/files/main.go b/tests/cases/gofmt/failure/files/main.go new file mode 100644 index 0000000..4e6c810 --- /dev/null +++ b/tests/cases/gofmt/failure/files/main.go @@ -0,0 +1,5 @@ +package main + +func add(x int,y int) int { +return x+y +} diff --git a/tests/cases/gofmt/failure/files/mise.toml b/tests/cases/gofmt/failure/files/mise.toml new file mode 100644 index 0000000..b55f8c1 --- /dev/null +++ b/tests/cases/gofmt/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "latest" diff --git a/tests/cases/gofmt/failure/test.toml b/tests/cases/gofmt/failure/test.toml new file mode 100644 index 0000000..02b61dc --- /dev/null +++ b/tests/cases/gofmt/failure/test.toml @@ -0,0 +1,20 @@ +[expected] +args = "run --full gofmt" +exit = 1 +stderr = ''' +[gofmt] +diff /main.go.orig /main.go +--- /main.go.orig ++++ /main.go +@@ -1,5 +1,5 @@ + package main + +-func add(x int,y int) int { +-return x+y ++func add(x int, y int) int { ++ return x + y + } + +flint: 1 check failed (gofmt) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/golangci-lint/clean/files/go.mod b/tests/cases/golangci-lint/clean/files/go.mod new file mode 100644 index 0000000..30c69d8 --- /dev/null +++ b/tests/cases/golangci-lint/clean/files/go.mod @@ -0,0 +1,3 @@ +module example.com/test + +go 1.21 diff --git a/tests/cases/golangci-lint/clean/files/main.go b/tests/cases/golangci-lint/clean/files/main.go new file mode 100644 index 0000000..99fd805 --- /dev/null +++ b/tests/cases/golangci-lint/clean/files/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Hello") +} diff --git a/tests/cases/golangci-lint/clean/files/mise.toml b/tests/cases/golangci-lint/clean/files/mise.toml new file mode 100644 index 0000000..748a0bc --- /dev/null +++ b/tests/cases/golangci-lint/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +golangci-lint = "latest" diff --git a/tests/cases/golangci-lint/clean/test.toml b/tests/cases/golangci-lint/clean/test.toml new file mode 100644 index 0000000..1e0d63c --- /dev/null +++ b/tests/cases/golangci-lint/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full golangci-lint" +exit = 0 diff --git a/tests/cases/golangci-lint/failure/files/go.mod b/tests/cases/golangci-lint/failure/files/go.mod new file mode 100644 index 0000000..30c69d8 --- /dev/null +++ b/tests/cases/golangci-lint/failure/files/go.mod @@ -0,0 +1,3 @@ +module example.com/test + +go 1.21 diff --git a/tests/cases/golangci-lint/failure/files/main.go b/tests/cases/golangci-lint/failure/files/main.go new file mode 100644 index 0000000..e06b39e --- /dev/null +++ b/tests/cases/golangci-lint/failure/files/main.go @@ -0,0 +1,7 @@ +package main + +import "os" + +func main() { + os.Remove("/tmp/test") +} diff --git a/tests/cases/golangci-lint/failure/files/mise.toml b/tests/cases/golangci-lint/failure/files/mise.toml new file mode 100644 index 0000000..748a0bc --- /dev/null +++ b/tests/cases/golangci-lint/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +golangci-lint = "latest" diff --git a/tests/cases/golangci-lint/failure/test.toml b/tests/cases/golangci-lint/failure/test.toml new file mode 100644 index 0000000..294fb2b --- /dev/null +++ b/tests/cases/golangci-lint/failure/test.toml @@ -0,0 +1,14 @@ +[expected] +args = "run --full golangci-lint" +exit = 1 +stderr = ''' +[golangci-lint] +main.go:6:11: Error return value of `os.Remove` is not checked (errcheck) + os.Remove("/tmp/test") + ^ +1 issues: +* errcheck: 1 + +flint: 1 check failed (golangci-lint) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/google-java-format/auto-fix/files/Hello.java b/tests/cases/google-java-format/auto-fix/files/Hello.java new file mode 100644 index 0000000..a605554 --- /dev/null +++ b/tests/cases/google-java-format/auto-fix/files/Hello.java @@ -0,0 +1,5 @@ +public class Hello { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} diff --git a/tests/cases/google-java-format/auto-fix/files/mise.toml b/tests/cases/google-java-format/auto-fix/files/mise.toml new file mode 100644 index 0000000..eb0a01b --- /dev/null +++ b/tests/cases/google-java-format/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:google/google-java-format" = "latest" diff --git a/tests/cases/google-java-format/auto-fix/test.toml b/tests/cases/google-java-format/auto-fix/test.toml new file mode 100644 index 0000000..259eea5 --- /dev/null +++ b/tests/cases/google-java-format/auto-fix/test.toml @@ -0,0 +1,15 @@ +[expected] +args = "run --full --fix google-java-format" +exit = 1 +stderr = ''' +flint: fixed: google-java-format — commit before pushing +''' + +[expected.files] +"Hello.java" = """ +public class Hello { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} +""" \ No newline at end of file diff --git a/tests/cases/google-java-format/clean/files/Hello.java b/tests/cases/google-java-format/clean/files/Hello.java new file mode 100644 index 0000000..386b0a9 --- /dev/null +++ b/tests/cases/google-java-format/clean/files/Hello.java @@ -0,0 +1,5 @@ +public class Hello { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} diff --git a/tests/cases/google-java-format/clean/files/mise.toml b/tests/cases/google-java-format/clean/files/mise.toml new file mode 100644 index 0000000..eb0a01b --- /dev/null +++ b/tests/cases/google-java-format/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:google/google-java-format" = "latest" diff --git a/tests/cases/google-java-format/clean/test.toml b/tests/cases/google-java-format/clean/test.toml new file mode 100644 index 0000000..c836985 --- /dev/null +++ b/tests/cases/google-java-format/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full google-java-format" +exit = 0 diff --git a/tests/cases/google-java-format/failure/files/Hello.java b/tests/cases/google-java-format/failure/files/Hello.java new file mode 100644 index 0000000..a605554 --- /dev/null +++ b/tests/cases/google-java-format/failure/files/Hello.java @@ -0,0 +1,5 @@ +public class Hello { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} diff --git a/tests/cases/google-java-format/failure/files/mise.toml b/tests/cases/google-java-format/failure/files/mise.toml new file mode 100644 index 0000000..eb0a01b --- /dev/null +++ b/tests/cases/google-java-format/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:google/google-java-format" = "latest" diff --git a/tests/cases/google-java-format/failure/test.toml b/tests/cases/google-java-format/failure/test.toml new file mode 100644 index 0000000..2340e93 --- /dev/null +++ b/tests/cases/google-java-format/failure/test.toml @@ -0,0 +1,10 @@ +[expected] +args = "run --full google-java-format" +exit = 1 +stderr = ''' +[google-java-format] +/Hello.java + +flint: 1 check failed (google-java-format) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/hadolint/clean/files/Dockerfile b/tests/cases/hadolint/clean/files/Dockerfile new file mode 100644 index 0000000..ff56c76 --- /dev/null +++ b/tests/cases/hadolint/clean/files/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:22.04 +COPY app /app +CMD ["/app"] diff --git a/tests/cases/hadolint/clean/files/mise.toml b/tests/cases/hadolint/clean/files/mise.toml new file mode 100644 index 0000000..bc333fe --- /dev/null +++ b/tests/cases/hadolint/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +hadolint = "latest" diff --git a/tests/cases/hadolint/clean/test.toml b/tests/cases/hadolint/clean/test.toml new file mode 100644 index 0000000..9d5d1e2 --- /dev/null +++ b/tests/cases/hadolint/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full hadolint" +exit = 0 diff --git a/tests/cases/hadolint/failure/files/Dockerfile b/tests/cases/hadolint/failure/files/Dockerfile new file mode 100644 index 0000000..53c3ea0 --- /dev/null +++ b/tests/cases/hadolint/failure/files/Dockerfile @@ -0,0 +1,2 @@ +FROM ubuntu:latest +RUN apt-get install -y curl diff --git a/tests/cases/hadolint/failure/files/mise.toml b/tests/cases/hadolint/failure/files/mise.toml new file mode 100644 index 0000000..bc333fe --- /dev/null +++ b/tests/cases/hadolint/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +hadolint = "latest" diff --git a/tests/cases/hadolint/failure/test.toml b/tests/cases/hadolint/failure/test.toml new file mode 100644 index 0000000..67d89b2 --- /dev/null +++ b/tests/cases/hadolint/failure/test.toml @@ -0,0 +1,12 @@ +[expected] +args = "run --full hadolint" +exit = 1 +stderr = ''' +[hadolint] +/Dockerfile:1 DL3007 warning: Using latest is prone to errors if the image will ever update. Pin the version explicitly to a release tag +/Dockerfile:2 DL3008 warning: Pin versions in apt get install. Instead of `apt-get install ` use `apt-get install =` +/Dockerfile:2 DL3015 info: Avoid additional packages by specifying `--no-install-recommends` + +flint: 1 check failed (hadolint) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/ktlint/auto-fix/files/Hello.kt b/tests/cases/ktlint/auto-fix/files/Hello.kt new file mode 100644 index 0000000..04a54e9 --- /dev/null +++ b/tests/cases/ktlint/auto-fix/files/Hello.kt @@ -0,0 +1,3 @@ +fun main() { + println("Hello") +} diff --git a/tests/cases/ktlint/auto-fix/files/mise.toml b/tests/cases/ktlint/auto-fix/files/mise.toml new file mode 100644 index 0000000..9a1910e --- /dev/null +++ b/tests/cases/ktlint/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:pinterest/ktlint" = "latest" diff --git a/tests/cases/ktlint/auto-fix/test.toml b/tests/cases/ktlint/auto-fix/test.toml new file mode 100644 index 0000000..0c3d580 --- /dev/null +++ b/tests/cases/ktlint/auto-fix/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full --fix ktlint" +exit = 1 +stderr = ''' +flint: fixed: ktlint — commit before pushing +''' + +[expected.files] +"Hello.kt" = """ +fun main() { + println("Hello") +} +""" \ No newline at end of file diff --git a/tests/cases/ktlint/clean/files/Hello.kt b/tests/cases/ktlint/clean/files/Hello.kt new file mode 100644 index 0000000..71c286e --- /dev/null +++ b/tests/cases/ktlint/clean/files/Hello.kt @@ -0,0 +1,3 @@ +fun main() { + println("Hello") +} diff --git a/tests/cases/ktlint/clean/files/mise.toml b/tests/cases/ktlint/clean/files/mise.toml new file mode 100644 index 0000000..9a1910e --- /dev/null +++ b/tests/cases/ktlint/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:pinterest/ktlint" = "latest" diff --git a/tests/cases/ktlint/clean/test.toml b/tests/cases/ktlint/clean/test.toml new file mode 100644 index 0000000..86797e4 --- /dev/null +++ b/tests/cases/ktlint/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full ktlint" +exit = 0 diff --git a/tests/cases/ktlint/failure/files/Hello.kt b/tests/cases/ktlint/failure/files/Hello.kt new file mode 100644 index 0000000..04a54e9 --- /dev/null +++ b/tests/cases/ktlint/failure/files/Hello.kt @@ -0,0 +1,3 @@ +fun main() { + println("Hello") +} diff --git a/tests/cases/ktlint/failure/files/mise.toml b/tests/cases/ktlint/failure/files/mise.toml new file mode 100644 index 0000000..9a1910e --- /dev/null +++ b/tests/cases/ktlint/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:pinterest/ktlint" = "latest" diff --git a/tests/cases/ktlint/failure/test.toml b/tests/cases/ktlint/failure/test.toml new file mode 100644 index 0000000..f5bcdb5 --- /dev/null +++ b/tests/cases/ktlint/failure/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full ktlint" +exit = 1 +stderr = ''' +[ktlint] +/Hello.kt:2:1: Unexpected indentation (2) (should be 4) (standard:indent) + +Summary error count (descending) by rule: + standard:indent: 1 + +flint: 1 check failed (ktlint) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/license-header/clean/files/Main.java b/tests/cases/license-header/clean/files/Main.java new file mode 100644 index 0000000..695b38a --- /dev/null +++ b/tests/cases/license-header/clean/files/Main.java @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +public class Main { + public static void main(String[] args) { + System.out.println("hello"); + } +} diff --git a/tests/cases/license-header/clean/files/flint.toml b/tests/cases/license-header/clean/files/flint.toml new file mode 100644 index 0000000..de980e0 --- /dev/null +++ b/tests/cases/license-header/clean/files/flint.toml @@ -0,0 +1,3 @@ +[checks.license-header] +text = "SPDX-License-Identifier: Apache-2.0" +patterns = ["*.java"] diff --git a/tests/cases/license-header/clean/test.toml b/tests/cases/license-header/clean/test.toml new file mode 100644 index 0000000..0861d7f --- /dev/null +++ b/tests/cases/license-header/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full license-header" +exit = 0 diff --git a/tests/cases/license-header/failure/files/Main.java b/tests/cases/license-header/failure/files/Main.java new file mode 100644 index 0000000..b8e3fe9 --- /dev/null +++ b/tests/cases/license-header/failure/files/Main.java @@ -0,0 +1,5 @@ +public class Main { + public static void main(String[] args) { + System.out.println("hello"); + } +} diff --git a/tests/cases/license-header/failure/files/flint.toml b/tests/cases/license-header/failure/files/flint.toml new file mode 100644 index 0000000..de980e0 --- /dev/null +++ b/tests/cases/license-header/failure/files/flint.toml @@ -0,0 +1,3 @@ +[checks.license-header] +text = "SPDX-License-Identifier: Apache-2.0" +patterns = ["*.java"] diff --git a/tests/cases/license-header/failure/test.toml b/tests/cases/license-header/failure/test.toml new file mode 100644 index 0000000..81d2d90 --- /dev/null +++ b/tests/cases/license-header/failure/test.toml @@ -0,0 +1,10 @@ +[expected] +args = "run --full license-header" +exit = 1 +stderr = ''' +[license-header] +Main.java: missing license header + +flint: 1 check failed (license-header) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/license-header/multiline-clean/files/Main.java b/tests/cases/license-header/multiline-clean/files/Main.java new file mode 100644 index 0000000..3086304 --- /dev/null +++ b/tests/cases/license-header/multiline-clean/files/Main.java @@ -0,0 +1,9 @@ +/* + * Copyright Grafana Labs + * SPDX-License-Identifier: Apache-2.0 + */ +public class Main { + public static void main(String[] args) { + System.out.println("hello"); + } +} diff --git a/tests/cases/license-header/multiline-clean/files/flint.toml b/tests/cases/license-header/multiline-clean/files/flint.toml new file mode 100644 index 0000000..2fe1e9b --- /dev/null +++ b/tests/cases/license-header/multiline-clean/files/flint.toml @@ -0,0 +1,7 @@ +[checks.license-header] +text = """ +/* + * Copyright Grafana Labs + * SPDX-License-Identifier: Apache-2.0 + */""" +patterns = ["*.java"] diff --git a/tests/cases/license-header/multiline-clean/test.toml b/tests/cases/license-header/multiline-clean/test.toml new file mode 100644 index 0000000..0861d7f --- /dev/null +++ b/tests/cases/license-header/multiline-clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full license-header" +exit = 0 diff --git a/tests/cases/lychee/broken-link/files/README.md b/tests/cases/lychee/broken-link/files/README.md new file mode 100644 index 0000000..7871ffd --- /dev/null +++ b/tests/cases/lychee/broken-link/files/README.md @@ -0,0 +1,3 @@ +# Test + +[broken link](./nonexistent.md) diff --git a/tests/cases/lychee/broken-link/files/lychee.toml b/tests/cases/lychee/broken-link/files/lychee.toml new file mode 100644 index 0000000..6f3ae43 --- /dev/null +++ b/tests/cases/lychee/broken-link/files/lychee.toml @@ -0,0 +1 @@ +# lychee configuration diff --git a/tests/cases/lychee/broken-link/files/mise.toml b/tests/cases/lychee/broken-link/files/mise.toml new file mode 100644 index 0000000..450fc84 --- /dev/null +++ b/tests/cases/lychee/broken-link/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +lychee = "latest" diff --git a/tests/cases/lychee/broken-link/test.toml b/tests/cases/lychee/broken-link/test.toml new file mode 100644 index 0000000..3f605e3 --- /dev/null +++ b/tests/cases/lychee/broken-link/test.toml @@ -0,0 +1,19 @@ +[expected] +args = "run --full lychee" +exit = 1 +stderr = ''' +[lychee] +==> Checking all links in all files +Issues found in 1 input. Find details below. + +[./README.md]: +[ERROR] file:///nonexistent.md | Cannot find file: File not found. Check if file exists and path is correct + +🔍 1 Total (in 0s) ✅ 0 OK 🚫 1 Error + +flint: 1 check failed (lychee) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' + +[env] +LYCHEE_SKIP_GITHUB_REMAPS = "true" diff --git a/tests/cases/lychee/clean/files/README.md b/tests/cases/lychee/clean/files/README.md new file mode 100644 index 0000000..59ece3c --- /dev/null +++ b/tests/cases/lychee/clean/files/README.md @@ -0,0 +1,3 @@ +# Test + +[valid link](./existing.md) diff --git a/tests/cases/lychee/clean/files/existing.md b/tests/cases/lychee/clean/files/existing.md new file mode 100644 index 0000000..d7fee48 --- /dev/null +++ b/tests/cases/lychee/clean/files/existing.md @@ -0,0 +1,3 @@ +# Existing file + +This file exists so the link in README.md resolves successfully. diff --git a/tests/cases/lychee/clean/files/lychee.toml b/tests/cases/lychee/clean/files/lychee.toml new file mode 100644 index 0000000..6f3ae43 --- /dev/null +++ b/tests/cases/lychee/clean/files/lychee.toml @@ -0,0 +1 @@ +# lychee configuration diff --git a/tests/cases/lychee/clean/files/mise.toml b/tests/cases/lychee/clean/files/mise.toml new file mode 100644 index 0000000..450fc84 --- /dev/null +++ b/tests/cases/lychee/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +lychee = "latest" diff --git a/tests/cases/lychee/clean/test.toml b/tests/cases/lychee/clean/test.toml new file mode 100644 index 0000000..0355a9f --- /dev/null +++ b/tests/cases/lychee/clean/test.toml @@ -0,0 +1,7 @@ +[expected] +args = "run --full lychee" +exit = 0 + + +[env] +LYCHEE_SKIP_GITHUB_REMAPS = "true" diff --git a/tests/cases/markdownlint-cli2/auto-fix/files/README.md b/tests/cases/markdownlint-cli2/auto-fix/files/README.md new file mode 100644 index 0000000..56c12ab --- /dev/null +++ b/tests/cases/markdownlint-cli2/auto-fix/files/README.md @@ -0,0 +1,5 @@ +# Title + +Text with trailing spaces + +## Section diff --git a/tests/cases/markdownlint-cli2/auto-fix/files/mise.toml b/tests/cases/markdownlint-cli2/auto-fix/files/mise.toml new file mode 100644 index 0000000..f8b2754 --- /dev/null +++ b/tests/cases/markdownlint-cli2/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +markdownlint-cli2 = "latest" diff --git a/tests/cases/markdownlint-cli2/auto-fix/test.toml b/tests/cases/markdownlint-cli2/auto-fix/test.toml new file mode 100644 index 0000000..9438367 --- /dev/null +++ b/tests/cases/markdownlint-cli2/auto-fix/test.toml @@ -0,0 +1,15 @@ +[expected] +args = "run --full --fix markdownlint-cli2" +exit = 1 +stderr = ''' +flint: fixed: markdownlint-cli2 — commit before pushing +''' + +[expected.files] +"README.md" = """ +# Title + +Text with trailing spaces + +## Section +""" \ No newline at end of file diff --git a/tests/cases/markdownlint-cli2/clean/files/README.md b/tests/cases/markdownlint-cli2/clean/files/README.md new file mode 100644 index 0000000..f8b741f --- /dev/null +++ b/tests/cases/markdownlint-cli2/clean/files/README.md @@ -0,0 +1,7 @@ +# Title + +Some text here. + +## Section + +More text. diff --git a/tests/cases/markdownlint-cli2/clean/files/mise.toml b/tests/cases/markdownlint-cli2/clean/files/mise.toml new file mode 100644 index 0000000..f8b2754 --- /dev/null +++ b/tests/cases/markdownlint-cli2/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +markdownlint-cli2 = "latest" diff --git a/tests/cases/markdownlint-cli2/clean/test.toml b/tests/cases/markdownlint-cli2/clean/test.toml new file mode 100644 index 0000000..2bc16c2 --- /dev/null +++ b/tests/cases/markdownlint-cli2/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full markdownlint-cli2" +exit = 0 diff --git a/tests/cases/markdownlint-cli2/failure/files/README.md b/tests/cases/markdownlint-cli2/failure/files/README.md new file mode 100644 index 0000000..56c12ab --- /dev/null +++ b/tests/cases/markdownlint-cli2/failure/files/README.md @@ -0,0 +1,5 @@ +# Title + +Text with trailing spaces + +## Section diff --git a/tests/cases/markdownlint-cli2/failure/files/mise.toml b/tests/cases/markdownlint-cli2/failure/files/mise.toml new file mode 100644 index 0000000..f8b2754 --- /dev/null +++ b/tests/cases/markdownlint-cli2/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +markdownlint-cli2 = "latest" diff --git a/tests/cases/markdownlint-cli2/failure/test.toml b/tests/cases/markdownlint-cli2/failure/test.toml new file mode 100644 index 0000000..11adb99 --- /dev/null +++ b/tests/cases/markdownlint-cli2/failure/test.toml @@ -0,0 +1,14 @@ +[expected] +args = "run --full markdownlint-cli2" +exit = 1 +stderr = ''' +[markdownlint-cli2] +markdownlint-cli2 v0.17.2 (markdownlint v0.37.4) +Finding: /README.md +Linting: 1 file(s) +Summary: 1 error(s) +README.md:3:26 MD009/no-trailing-spaces Trailing spaces [Expected: 0 or 2; Actual: 3] + +flint: 1 check failed (markdownlint-cli2) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/prettier/auto-fix/files/config.yml b/tests/cases/prettier/auto-fix/files/config.yml new file mode 100644 index 0000000..d2d0e9b --- /dev/null +++ b/tests/cases/prettier/auto-fix/files/config.yml @@ -0,0 +1,3 @@ +name: "test" +value: 42 +items: ["a","b"] diff --git a/tests/cases/prettier/auto-fix/files/mise.toml b/tests/cases/prettier/auto-fix/files/mise.toml new file mode 100644 index 0000000..789d69a --- /dev/null +++ b/tests/cases/prettier/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +prettier = "latest" diff --git a/tests/cases/prettier/auto-fix/test.toml b/tests/cases/prettier/auto-fix/test.toml new file mode 100644 index 0000000..3690de9 --- /dev/null +++ b/tests/cases/prettier/auto-fix/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full --fix prettier" +exit = 1 +stderr = ''' +flint: fixed: prettier — commit before pushing +''' + +[expected.files] +"config.yml" = """ +name: "test" +value: 42 +items: ["a", "b"] +""" \ No newline at end of file diff --git a/tests/cases/prettier/clean/files/config.yml b/tests/cases/prettier/clean/files/config.yml new file mode 100644 index 0000000..adde7d6 --- /dev/null +++ b/tests/cases/prettier/clean/files/config.yml @@ -0,0 +1,2 @@ +name: test +value: 42 diff --git a/tests/cases/prettier/clean/files/mise.toml b/tests/cases/prettier/clean/files/mise.toml new file mode 100644 index 0000000..789d69a --- /dev/null +++ b/tests/cases/prettier/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +prettier = "latest" diff --git a/tests/cases/prettier/clean/test.toml b/tests/cases/prettier/clean/test.toml new file mode 100644 index 0000000..ea50f65 --- /dev/null +++ b/tests/cases/prettier/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full prettier" +exit = 0 diff --git a/tests/cases/prettier/failure/files/config.yml b/tests/cases/prettier/failure/files/config.yml new file mode 100644 index 0000000..d2d0e9b --- /dev/null +++ b/tests/cases/prettier/failure/files/config.yml @@ -0,0 +1,3 @@ +name: "test" +value: 42 +items: ["a","b"] diff --git a/tests/cases/prettier/failure/files/mise.toml b/tests/cases/prettier/failure/files/mise.toml new file mode 100644 index 0000000..789d69a --- /dev/null +++ b/tests/cases/prettier/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +prettier = "latest" diff --git a/tests/cases/prettier/failure/test.toml b/tests/cases/prettier/failure/test.toml new file mode 100644 index 0000000..4caf9ed --- /dev/null +++ b/tests/cases/prettier/failure/test.toml @@ -0,0 +1,12 @@ +[expected] +args = "run --full prettier" +exit = 1 +stderr = ''' +[prettier] +Checking formatting... +[warn] config.yml +[warn] Code style issues found in the above file. Run Prettier with --write to fix. + +flint: 1 check failed (prettier) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/renovate-deps/fix-create/files/.github/renovate.json5 b/tests/cases/renovate-deps/fix-create/files/.github/renovate.json5 new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/renovate-deps/fix-create/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/renovate-deps/fix-create/files/mise.toml b/tests/cases/renovate-deps/fix-create/files/mise.toml new file mode 100644 index 0000000..bd7b0a1 --- /dev/null +++ b/tests/cases/renovate-deps/fix-create/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +"npm:renovate" = "latest" + diff --git a/tests/cases/renovate-deps/fix-create/files/package.json b/tests/cases/renovate-deps/fix-create/files/package.json new file mode 100644 index 0000000..19f0753 --- /dev/null +++ b/tests/cases/renovate-deps/fix-create/files/package.json @@ -0,0 +1 @@ +{"dependencies":{"express":"^4.18.0","lodash":"^4.17.21"}} diff --git a/tests/cases/renovate-deps/fix-create/test.toml b/tests/cases/renovate-deps/fix-create/test.toml new file mode 100644 index 0000000..ad50864 --- /dev/null +++ b/tests/cases/renovate-deps/fix-create/test.toml @@ -0,0 +1,23 @@ +[expected] +args = "run --full --fix renovate-deps" +exit = 1 +stderr = ''' +flint: fixed: renovate-deps — commit before pushing +''' + +[expected.files] +".github/renovate-tracked-deps.json" = """ +{ + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, + "package.json": { + "npm": [ + "express", + "lodash" + ] + } +} +""" \ No newline at end of file diff --git a/tests/cases/renovate-deps/fix-update/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/fix-update/files/.github/renovate-tracked-deps.json new file mode 100644 index 0000000..167d26b --- /dev/null +++ b/tests/cases/renovate-deps/fix-update/files/.github/renovate-tracked-deps.json @@ -0,0 +1,7 @@ +{ + "package.json": { + "npm": [ + "old-dep" + ] + } +} diff --git a/tests/cases/renovate-deps/fix-update/files/.github/renovate.json5 b/tests/cases/renovate-deps/fix-update/files/.github/renovate.json5 new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/renovate-deps/fix-update/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/renovate-deps/fix-update/files/mise.toml b/tests/cases/renovate-deps/fix-update/files/mise.toml new file mode 100644 index 0000000..bd7b0a1 --- /dev/null +++ b/tests/cases/renovate-deps/fix-update/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +"npm:renovate" = "latest" + diff --git a/tests/cases/renovate-deps/fix-update/files/package.json b/tests/cases/renovate-deps/fix-update/files/package.json new file mode 100644 index 0000000..19f0753 --- /dev/null +++ b/tests/cases/renovate-deps/fix-update/files/package.json @@ -0,0 +1 @@ +{"dependencies":{"express":"^4.18.0","lodash":"^4.17.21"}} diff --git a/tests/cases/renovate-deps/fix-update/test.toml b/tests/cases/renovate-deps/fix-update/test.toml new file mode 100644 index 0000000..ad50864 --- /dev/null +++ b/tests/cases/renovate-deps/fix-update/test.toml @@ -0,0 +1,23 @@ +[expected] +args = "run --full --fix renovate-deps" +exit = 1 +stderr = ''' +flint: fixed: renovate-deps — commit before pushing +''' + +[expected.files] +".github/renovate-tracked-deps.json" = """ +{ + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, + "package.json": { + "npm": [ + "express", + "lodash" + ] + } +} +""" \ No newline at end of file diff --git a/tests/cases/renovate-deps/out-of-date/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/out-of-date/files/.github/renovate-tracked-deps.json new file mode 100644 index 0000000..167d26b --- /dev/null +++ b/tests/cases/renovate-deps/out-of-date/files/.github/renovate-tracked-deps.json @@ -0,0 +1,7 @@ +{ + "package.json": { + "npm": [ + "old-dep" + ] + } +} diff --git a/tests/cases/renovate-deps/out-of-date/files/.github/renovate.json5 b/tests/cases/renovate-deps/out-of-date/files/.github/renovate.json5 new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/renovate-deps/out-of-date/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/renovate-deps/out-of-date/files/mise.toml b/tests/cases/renovate-deps/out-of-date/files/mise.toml new file mode 100644 index 0000000..bd7b0a1 --- /dev/null +++ b/tests/cases/renovate-deps/out-of-date/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +"npm:renovate" = "latest" + diff --git a/tests/cases/renovate-deps/out-of-date/files/package.json b/tests/cases/renovate-deps/out-of-date/files/package.json new file mode 100644 index 0000000..19f0753 --- /dev/null +++ b/tests/cases/renovate-deps/out-of-date/files/package.json @@ -0,0 +1 @@ +{"dependencies":{"express":"^4.18.0","lodash":"^4.17.21"}} diff --git a/tests/cases/renovate-deps/out-of-date/test.toml b/tests/cases/renovate-deps/out-of-date/test.toml new file mode 100644 index 0000000..4561872 --- /dev/null +++ b/tests/cases/renovate-deps/out-of-date/test.toml @@ -0,0 +1,39 @@ +[expected] +args = "run --full renovate-deps" +exit = 1 +stderr = ''' +[renovate-deps] +--- .github/renovate-tracked-deps.json ++++ generated +@@ -1,7 +1,13 @@ + { ++ "mise.toml": { ++ "mise": [ ++ "npm:renovate" ++ ] ++ }, + "package.json": { + "npm": [ +- "old-dep" ++ "express", ++ "lodash" + ] + } + } +ERROR: renovate-tracked-deps.json is out of date. +Run `flint run --fix renovate-deps` to update. + +flint: 1 check failed (renovate-deps) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' + +[expected.files] +".github/renovate-tracked-deps.json" = """ +{ + "package.json": { + "npm": [ + "old-dep" + ] + } +} +""" \ No newline at end of file diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/.renovaterc.json b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/.renovaterc.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/.renovaterc.json @@ -0,0 +1 @@ +{} diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/mise.toml b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/mise.toml new file mode 100644 index 0000000..09e8396 --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"npm:renovate" = "latest" diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/package.json b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/package.json new file mode 100644 index 0000000..19f0753 --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/package.json @@ -0,0 +1 @@ +{"dependencies":{"express":"^4.18.0","lodash":"^4.17.21"}} diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/renovate-tracked-deps.json b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/renovate-tracked-deps.json new file mode 100644 index 0000000..41f0847 --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/files/renovate-tracked-deps.json @@ -0,0 +1,13 @@ +{ + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, + "package.json": { + "npm": [ + "express", + "lodash" + ] + } +} diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml b/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml new file mode 100644 index 0000000..d8b049b --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full renovate-deps" +exit = 0 diff --git a/tests/cases/renovate-deps/up-to-date/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/up-to-date/files/.github/renovate-tracked-deps.json new file mode 100644 index 0000000..41f0847 --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date/files/.github/renovate-tracked-deps.json @@ -0,0 +1,13 @@ +{ + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, + "package.json": { + "npm": [ + "express", + "lodash" + ] + } +} diff --git a/tests/cases/renovate-deps/up-to-date/files/.github/renovate.json5 b/tests/cases/renovate-deps/up-to-date/files/.github/renovate.json5 new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/cases/renovate-deps/up-to-date/files/mise.toml b/tests/cases/renovate-deps/up-to-date/files/mise.toml new file mode 100644 index 0000000..bd7b0a1 --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +"npm:renovate" = "latest" + diff --git a/tests/cases/renovate-deps/up-to-date/files/package.json b/tests/cases/renovate-deps/up-to-date/files/package.json new file mode 100644 index 0000000..19f0753 --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date/files/package.json @@ -0,0 +1 @@ +{"dependencies":{"express":"^4.18.0","lodash":"^4.17.21"}} diff --git a/tests/cases/renovate-deps/up-to-date/test.toml b/tests/cases/renovate-deps/up-to-date/test.toml new file mode 100644 index 0000000..d8b049b --- /dev/null +++ b/tests/cases/renovate-deps/up-to-date/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full renovate-deps" +exit = 0 diff --git a/tests/cases/ruff-format/auto-fix/files/main.py b/tests/cases/ruff-format/auto-fix/files/main.py new file mode 100644 index 0000000..d7826e7 --- /dev/null +++ b/tests/cases/ruff-format/auto-fix/files/main.py @@ -0,0 +1,3 @@ +x=1 +y = 2 +z=x+y diff --git a/tests/cases/ruff-format/auto-fix/files/mise.toml b/tests/cases/ruff-format/auto-fix/files/mise.toml new file mode 100644 index 0000000..7e04388 --- /dev/null +++ b/tests/cases/ruff-format/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +ruff = "latest" diff --git a/tests/cases/ruff-format/auto-fix/test.toml b/tests/cases/ruff-format/auto-fix/test.toml new file mode 100644 index 0000000..3ce0b88 --- /dev/null +++ b/tests/cases/ruff-format/auto-fix/test.toml @@ -0,0 +1,13 @@ +[expected] +args = "run --full --fix ruff-format" +exit = 1 +stderr = ''' +flint: fixed: ruff-format — commit before pushing +''' + +[expected.files] +"main.py" = """ +x = 1 +y = 2 +z = x + y +""" \ No newline at end of file diff --git a/tests/cases/ruff-format/clean/files/main.py b/tests/cases/ruff-format/clean/files/main.py new file mode 100644 index 0000000..2b73c17 --- /dev/null +++ b/tests/cases/ruff-format/clean/files/main.py @@ -0,0 +1,3 @@ +x = 1 +y = 2 +z = x + y diff --git a/tests/cases/ruff-format/clean/files/mise.toml b/tests/cases/ruff-format/clean/files/mise.toml new file mode 100644 index 0000000..7e04388 --- /dev/null +++ b/tests/cases/ruff-format/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +ruff = "latest" diff --git a/tests/cases/ruff-format/clean/test.toml b/tests/cases/ruff-format/clean/test.toml new file mode 100644 index 0000000..2207f9d --- /dev/null +++ b/tests/cases/ruff-format/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full ruff-format" +exit = 0 diff --git a/tests/cases/ruff-format/failure/files/main.py b/tests/cases/ruff-format/failure/files/main.py new file mode 100644 index 0000000..d7826e7 --- /dev/null +++ b/tests/cases/ruff-format/failure/files/main.py @@ -0,0 +1,3 @@ +x=1 +y = 2 +z=x+y diff --git a/tests/cases/ruff-format/failure/files/mise.toml b/tests/cases/ruff-format/failure/files/mise.toml new file mode 100644 index 0000000..7e04388 --- /dev/null +++ b/tests/cases/ruff-format/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +ruff = "latest" diff --git a/tests/cases/ruff-format/failure/test.toml b/tests/cases/ruff-format/failure/test.toml new file mode 100644 index 0000000..6988eef --- /dev/null +++ b/tests/cases/ruff-format/failure/test.toml @@ -0,0 +1,11 @@ +[expected] +args = "run --full ruff-format" +exit = 1 +stderr = ''' +[ruff-format] +Would reformat: main.py +1 file would be reformatted + +flint: 1 check failed (ruff-format) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/ruff/clean/files/main.py b/tests/cases/ruff/clean/files/main.py new file mode 100644 index 0000000..1e21eeb --- /dev/null +++ b/tests/cases/ruff/clean/files/main.py @@ -0,0 +1,2 @@ +def greet(name: str) -> str: + return f"Hello, {name}!" diff --git a/tests/cases/ruff/clean/files/mise.toml b/tests/cases/ruff/clean/files/mise.toml new file mode 100644 index 0000000..7e04388 --- /dev/null +++ b/tests/cases/ruff/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +ruff = "latest" diff --git a/tests/cases/ruff/clean/test.toml b/tests/cases/ruff/clean/test.toml new file mode 100644 index 0000000..1943648 --- /dev/null +++ b/tests/cases/ruff/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full ruff" +exit = 0 diff --git a/tests/cases/ruff/failure/files/main.py b/tests/cases/ruff/failure/files/main.py new file mode 100644 index 0000000..75f2eca --- /dev/null +++ b/tests/cases/ruff/failure/files/main.py @@ -0,0 +1,3 @@ +import os + +print("hello") diff --git a/tests/cases/ruff/failure/files/mise.toml b/tests/cases/ruff/failure/files/mise.toml new file mode 100644 index 0000000..7e04388 --- /dev/null +++ b/tests/cases/ruff/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +ruff = "latest" diff --git a/tests/cases/ruff/failure/test.toml b/tests/cases/ruff/failure/test.toml new file mode 100644 index 0000000..b7f3a52 --- /dev/null +++ b/tests/cases/ruff/failure/test.toml @@ -0,0 +1,21 @@ +[expected] +args = "run --full ruff" +exit = 1 +stderr = ''' +[ruff] +F401 [*] `os` imported but unused + --> main.py:1:8 + | +1 | import os + | ^^ +2 | +3 | print("hello") + | +help: Remove unused import: `os` + +Found 1 error. +[*] 1 fixable with the `--fix` option. + +flint: 1 check failed (ruff) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/shellcheck/clean/files/good.sh b/tests/cases/shellcheck/clean/files/good.sh new file mode 100644 index 0000000..ccc95ec --- /dev/null +++ b/tests/cases/shellcheck/clean/files/good.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo "$1" diff --git a/tests/cases/shellcheck/clean/files/mise.toml b/tests/cases/shellcheck/clean/files/mise.toml new file mode 100644 index 0000000..d725983 --- /dev/null +++ b/tests/cases/shellcheck/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shellcheck = "latest" diff --git a/tests/cases/shellcheck/clean/test.toml b/tests/cases/shellcheck/clean/test.toml new file mode 100644 index 0000000..0ce0ca5 --- /dev/null +++ b/tests/cases/shellcheck/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full shellcheck" +exit = 0 diff --git a/tests/cases/shellcheck/config-dir/files/bad.sh b/tests/cases/shellcheck/config-dir/files/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/shellcheck/config-dir/files/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/shellcheck/config-dir/files/config/.shellcheckrc b/tests/cases/shellcheck/config-dir/files/config/.shellcheckrc new file mode 100644 index 0000000..5de1df3 --- /dev/null +++ b/tests/cases/shellcheck/config-dir/files/config/.shellcheckrc @@ -0,0 +1 @@ +disable=SC2086 diff --git a/tests/cases/shellcheck/config-dir/files/mise.toml b/tests/cases/shellcheck/config-dir/files/mise.toml new file mode 100644 index 0000000..d725983 --- /dev/null +++ b/tests/cases/shellcheck/config-dir/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shellcheck = "latest" diff --git a/tests/cases/shellcheck/config-dir/test.toml b/tests/cases/shellcheck/config-dir/test.toml new file mode 100644 index 0000000..10cb873 --- /dev/null +++ b/tests/cases/shellcheck/config-dir/test.toml @@ -0,0 +1,7 @@ +[expected] +args = "run --full shellcheck" +exit = 0 + + +[env] +FLINT_CONFIG_DIR = "config" diff --git a/tests/cases/shellcheck/failure/files/bad.sh b/tests/cases/shellcheck/failure/files/bad.sh new file mode 100644 index 0000000..706457d --- /dev/null +++ b/tests/cases/shellcheck/failure/files/bad.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo $1 diff --git a/tests/cases/shellcheck/failure/files/mise.toml b/tests/cases/shellcheck/failure/files/mise.toml new file mode 100644 index 0000000..d725983 --- /dev/null +++ b/tests/cases/shellcheck/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +shellcheck = "latest" diff --git a/tests/cases/shellcheck/failure/test.toml b/tests/cases/shellcheck/failure/test.toml new file mode 100644 index 0000000..71e11cd --- /dev/null +++ b/tests/cases/shellcheck/failure/test.toml @@ -0,0 +1,19 @@ +[expected] +args = "run --full shellcheck" +exit = 1 +stderr = ''' +[shellcheck] + +In /bad.sh line 2: +echo $1 + ^-- SC2086 (info): Double quote to prevent globbing and word splitting. + +Did you mean: +echo "$1" + +For more information: + https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... + +flint: 1 check failed (shellcheck) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/shfmt/auto-fix/files/mise.toml b/tests/cases/shfmt/auto-fix/files/mise.toml new file mode 100644 index 0000000..aa00ef2 --- /dev/null +++ b/tests/cases/shfmt/auto-fix/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:mvdan/sh" = "v3.12.0" diff --git a/tests/cases/shfmt/auto-fix/files/script.sh b/tests/cases/shfmt/auto-fix/files/script.sh new file mode 100644 index 0000000..e66e97f --- /dev/null +++ b/tests/cases/shfmt/auto-fix/files/script.sh @@ -0,0 +1,4 @@ +#!/bin/sh +if true; then + echo "hello" +fi diff --git a/tests/cases/shfmt/auto-fix/test.toml b/tests/cases/shfmt/auto-fix/test.toml new file mode 100644 index 0000000..feb46d6 --- /dev/null +++ b/tests/cases/shfmt/auto-fix/test.toml @@ -0,0 +1,14 @@ +[expected] +args = "run --full --fix shfmt" +exit = 1 +stderr = ''' +flint: fixed: shfmt — commit before pushing +''' + +[expected.files] +"script.sh" = """ +#!/bin/sh +if true; then + echo "hello" +fi +""" \ No newline at end of file diff --git a/tests/cases/shfmt/clean/files/mise.toml b/tests/cases/shfmt/clean/files/mise.toml new file mode 100644 index 0000000..aa00ef2 --- /dev/null +++ b/tests/cases/shfmt/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:mvdan/sh" = "v3.12.0" diff --git a/tests/cases/shfmt/clean/files/script.sh b/tests/cases/shfmt/clean/files/script.sh new file mode 100644 index 0000000..ba88b94 --- /dev/null +++ b/tests/cases/shfmt/clean/files/script.sh @@ -0,0 +1,4 @@ +#!/bin/sh +if true; then + echo "hello" +fi diff --git a/tests/cases/shfmt/clean/test.toml b/tests/cases/shfmt/clean/test.toml new file mode 100644 index 0000000..73fe4ce --- /dev/null +++ b/tests/cases/shfmt/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full shfmt" +exit = 0 diff --git a/tests/cases/shfmt/failure/files/mise.toml b/tests/cases/shfmt/failure/files/mise.toml new file mode 100644 index 0000000..aa00ef2 --- /dev/null +++ b/tests/cases/shfmt/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"github:mvdan/sh" = "v3.12.0" diff --git a/tests/cases/shfmt/failure/files/script.sh b/tests/cases/shfmt/failure/files/script.sh new file mode 100644 index 0000000..e66e97f --- /dev/null +++ b/tests/cases/shfmt/failure/files/script.sh @@ -0,0 +1,4 @@ +#!/bin/sh +if true; then + echo "hello" +fi diff --git a/tests/cases/shfmt/failure/test.toml b/tests/cases/shfmt/failure/test.toml new file mode 100644 index 0000000..89700a0 --- /dev/null +++ b/tests/cases/shfmt/failure/test.toml @@ -0,0 +1,18 @@ +[expected] +args = "run --full shfmt" +exit = 1 +stderr = ''' +[shfmt] +diff /script.sh.orig /script.sh +--- /script.sh.orig ++++ /script.sh +@@ -1,4 +1,4 @@ + #!/bin/sh + if true; then +- echo "hello" ++ echo "hello" + fi + +flint: 1 check failed (shfmt) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/cases/xmllint/clean/files/mise.toml b/tests/cases/xmllint/clean/files/mise.toml new file mode 100644 index 0000000..9c883ed --- /dev/null +++ b/tests/cases/xmllint/clean/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"cargo:xmloxide" = "0.4.1" diff --git a/tests/cases/xmllint/clean/files/pom.xml b/tests/cases/xmllint/clean/files/pom.xml new file mode 100644 index 0000000..3c5b346 --- /dev/null +++ b/tests/cases/xmllint/clean/files/pom.xml @@ -0,0 +1,4 @@ + + io.prometheus + client_java + diff --git a/tests/cases/xmllint/clean/test.toml b/tests/cases/xmllint/clean/test.toml new file mode 100644 index 0000000..8cc15a8 --- /dev/null +++ b/tests/cases/xmllint/clean/test.toml @@ -0,0 +1,3 @@ +[expected] +args = "run --full xmllint" +exit = 0 diff --git a/tests/cases/xmllint/failure/files/mise.toml b/tests/cases/xmllint/failure/files/mise.toml new file mode 100644 index 0000000..9c883ed --- /dev/null +++ b/tests/cases/xmllint/failure/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"cargo:xmloxide" = "0.4.1" diff --git a/tests/cases/xmllint/failure/files/pom.xml b/tests/cases/xmllint/failure/files/pom.xml new file mode 100644 index 0000000..8221d67 --- /dev/null +++ b/tests/cases/xmllint/failure/files/pom.xml @@ -0,0 +1,2 @@ + + diff --git a/tests/cases/xmllint/failure/test.toml b/tests/cases/xmllint/failure/test.toml new file mode 100644 index 0000000..c0630f4 --- /dev/null +++ b/tests/cases/xmllint/failure/test.toml @@ -0,0 +1,10 @@ +[expected] +args = "run --full xmllint" +exit = 1 +stderr = ''' +[xmllint] +/pom.xml: parse error at 3:1: unexpected end of input in element content + +flint: 1 check failed (xmllint) +💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. +''' \ No newline at end of file diff --git a/tests/e2e.rs b/tests/e2e.rs new file mode 100644 index 0000000..7eeba5e --- /dev/null +++ b/tests/e2e.rs @@ -0,0 +1,543 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::{Arc, Mutex}; +use tempfile::TempDir; + +/// Runs the flint binary with additional environment variables. +fn flint_with_env(args: &[&str], cwd: &Path, env: &[(&str, &str)]) -> Output { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_flint")); + cmd.args(args) + .env("MISE_PROJECT_ROOT", cwd) + .env_remove("FLINT_CONFIG_DIR") + .current_dir(cwd); + for (k, v) in env { + cmd.env(k, v); + } + cmd.output().expect("failed to spawn flint") +} + +/// Creates a temp directory initialised as a git repo with branch `main`. +fn git_repo() -> TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + for args in [ + vec!["init", "-b", "main"], + vec!["config", "user.email", "test@test.com"], + vec!["config", "user.name", "Test"], + ] { + let out = Command::new("git") + .args(&args) + .current_dir(dir.path()) + .output() + .expect("failed to spawn git"); + assert!( + out.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&out.stderr) + ); + } + dir +} + +/// Runs all fixture cases under tests/cases/. +/// Each case is a directory containing: +/// files/ — files to copy into the repo and stage +/// test.toml — args, expected exit code, and golden output +/// +/// test.toml format: +/// [expected] +/// args = "--full --fix shellcheck" +/// exit = 1 # optional, default 0 +/// stderr = """...""" # optional, default "" +/// stdout = """...""" # optional, default "" +/// +/// [expected.files] # optional file contents asserted after run +/// ".github/renovate-tracked-deps.json" = """...""" +/// +/// [env] # optional extra env vars +/// KEY = "value" +/// +/// [fake_bins] # optional fake binaries (Unix only) +/// renovate = ''' +/// #!/bin/sh +/// echo '...' +/// ''' +/// +/// Set UPDATE_SNAPSHOTS=1 to regenerate golden output in test.toml. +/// Set FLINT_CASES=

to run only cases under that directory (e.g. FLINT_CASES=shellcheck +/// or FLINT_CASES=shellcheck/clean). Top-level groups run in parallel. +/// +/// Cases that declare `[fake_bins]` are skipped on non-Unix platforms because the +/// fake binaries are shell scripts. All other cases run on every platform. +#[test] +fn cases() { + let cases_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); + let update = std::env::var("UPDATE_SNAPSHOTS").is_ok(); + let filter = std::env::var("FLINT_CASES").ok(); + + let mut case_paths = collect_cases(&cases_dir); + case_paths.sort(); + + if let Some(ref f) = filter { + case_paths.retain(|p| { + let name = p.strip_prefix(&cases_dir).unwrap().to_string_lossy(); + name.starts_with(f.as_str()) + }); + if case_paths.is_empty() { + panic!("FLINT_CASES={f}: no matching cases found"); + } + } + + // Group by top-level directory (linter name) so each group runs in its own thread. + let mut groups: BTreeMap> = BTreeMap::new(); + for path in case_paths { + let top = path + .strip_prefix(&cases_dir) + .unwrap() + .components() + .next() + .unwrap() + .as_os_str() + .to_string_lossy() + .into_owned(); + groups.entry(top).or_default().push(path); + } + + let failures: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let handles: Vec<_> = groups + .into_values() + .map(|paths| { + let cases_dir = cases_dir.clone(); + let failures = Arc::clone(&failures); + std::thread::spawn(move || { + for case in &paths { + let name = case + .strip_prefix(&cases_dir) + .unwrap() + .to_string_lossy() + .into_owned(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_case(case, &name, update); + })); + if let Err(e) = result { + let msg = e + .downcast_ref::() + .cloned() + .or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| format!("panic in {name}")); + failures.lock().unwrap().push(format!( + "FAILED: {name}\n{msg}\n → rerun: FLINT_CASES={name} cargo test cases" + )); + } + } + }) + }) + .collect(); + + for h in handles { + h.join().unwrap(); + } + + let failures = failures.lock().unwrap(); + if !failures.is_empty() { + panic!( + "\n\n{}\n\n{} case(s) failed", + failures.join("\n\n"), + failures.len() + ); + } +} + +/// Recursively finds all directories containing a `test.toml` file. +fn collect_cases(dir: &Path) -> Vec { + let mut cases = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return cases; + }; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.is_dir() { + if path.join("test.toml").exists() { + cases.push(path); + } else { + cases.extend(collect_cases(&path)); + } + } + } + cases +} + +fn run_case(case: &Path, name: &str, update: bool) { + let toml_path = case.join("test.toml"); + let raw = + std::fs::read_to_string(&toml_path).unwrap_or_else(|_| panic!("{name}: missing test.toml")); + let cfg: toml::Value = + toml::from_str(&raw).unwrap_or_else(|e| panic!("{name}: invalid test.toml: {e}")); + + let expected = cfg + .get("expected") + .unwrap_or_else(|| panic!("{name}: missing [expected] table")); + let args_str = expected["args"] + .as_str() + .unwrap_or_else(|| panic!("{name}: missing expected.args")); + let args: Vec<&str> = args_str.split_whitespace().collect(); + let expected_exit = expected + .get("exit") + .and_then(|v| v.as_integer()) + .unwrap_or(0) as i32; + + // Skip cases that use shell-script fake binaries on non-Unix platforms. + #[cfg(not(unix))] + if cfg + .get("fake_bins") + .and_then(|v| v.as_table()) + .is_some_and(|t| !t.is_empty()) + { + eprintln!("{name}: skipped (fake_bins requires Unix)"); + return; + } + + let repo = git_repo(); + + let files_dir = case.join("files"); + copy_dir_into(&files_dir, repo.path()); + 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) + ); + + // If a `changes/` directory exists alongside `files/`, write those files + // over the repo and stage them (but don't commit). This lets fixtures test + // the changed-files code path (as opposed to --full / all-files mode). + let changes_dir = case.join("changes"); + if changes_dir.exists() { + copy_dir_into(&changes_dir, repo.path()); + 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 changes failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + let env_vars: Vec<(String, String)> = cfg + .get("env") + .and_then(|v| v.as_table()) + .map(|t| { + t.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + + // Write fake binaries into a temp dir and prepend it to PATH. + // The tempdir must stay alive until after flint_with_env returns. + let fake_bin_dir = tempfile::tempdir().expect("fake_bin tempdir"); + let fake_path = setup_fake_bins(&cfg, name, fake_bin_dir.path()); + + let mut env_refs: Vec<(&str, &str)> = env_vars + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + if let Some(ref p) = fake_path { + env_refs.push(("PATH", p.as_str())); + } + + let out = flint_with_env(&args, repo.path(), &env_refs); + + let repo_str = repo.path().to_string_lossy(); + let repo_canonical_str = canonical_repo_path(repo.path()); + let normalize = + |s: String| -> String { normalize_output(s, repo_str.as_ref(), &repo_canonical_str) }; + let stderr = normalize_timing(&strip_ansi(&normalize( + String::from_utf8_lossy(&out.stderr).into_owned(), + ))); + let stdout = normalize_timing(&strip_ansi(&normalize( + String::from_utf8_lossy(&out.stdout).into_owned(), + ))); + + if update { + write_test_toml( + &toml_path, + &cfg, + out.status.code().unwrap_or(0) as i32, + &stderr, + &stdout, + ); + println!("{name}: snapshots updated"); + return; + } + + let exp_stderr = expected + .get("stderr") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let exp_stdout = expected + .get("stdout") + .and_then(|v| v.as_str()) + .unwrap_or(""); + assert_eq!(stderr, exp_stderr, "{name}: stderr mismatch"); + assert_eq!(stdout, exp_stdout, "{name}: stdout mismatch"); + assert_eq!( + out.status.code(), + Some(expected_exit), + "{name}: exit code mismatch" + ); + + // Assert file contents written by flint (e.g. fix mode snapshots). + if let Some(files) = expected.get("files").and_then(|v| v.as_table()) { + for (rel_path, exp) in files { + let exp = exp + .as_str() + .unwrap_or_else(|| panic!("{name}: expected.files.{rel_path} must be a string")); + let actual = std::fs::read_to_string(repo.path().join(rel_path)) + .unwrap_or_else(|e| panic!("{name}: expected.files.{rel_path}: {e}")); + assert_eq!(actual, exp, "{name}: {rel_path} content mismatch"); + } + } +} + +/// Rewrites test.toml updating snapshot fields ([expected].exit/stderr/stdout) +/// while preserving everything else (args, env, fake_bins, expected.files). +fn write_test_toml(path: &Path, cfg: &toml::Value, exit: i32, stderr: &str, stdout: &str) { + let args_str = cfg["expected"]["args"].as_str().unwrap_or(""); + let existing_files = cfg + .get("expected") + .and_then(|v| v.get("files")) + .and_then(|v| v.as_table()); + + let mut out = String::from("[expected]\n"); + out += &format!("args = \"{}\"\n", toml_escape(args_str)); + out += &format!("exit = {exit}\n"); + if !stderr.is_empty() { + out += &format!("stderr = '''\n{stderr}'''"); + } + if !stdout.is_empty() { + out += &format!("stdout = '''\n{stdout}'''"); + } + if let Some(files) = existing_files { + out += "\n\n[expected.files]\n"; + for (k, v) in files { + if let Some(s) = v.as_str() { + out += &format!("\"{k}\" = \"\"\"\n{s}\"\"\""); + } + } + } + + if let Some(env) = cfg.get("env").and_then(|v| v.as_table()) { + if !env.is_empty() { + out += "\n\n[env]\n"; + for (k, v) in env { + if let Some(s) = v.as_str() { + out += &format!("{k} = \"{}\"\n", toml_escape(s)); + } + } + } + } + + // Serialize as multiline literal strings so shell scripts stay readable. + // TOML trims the first newline after ''', so '''\n{s}''' roundtrips cleanly. + if let Some(bins) = cfg.get("fake_bins").and_then(|v| v.as_table()) { + if !bins.is_empty() { + out += "\n[fake_bins]\n"; + for (k, v) in bins { + if let Some(s) = v.as_str() { + out += &format!("{k} = '''\n{s}'''\n"); + } + } + } + } + + std::fs::write(path, out).unwrap(); +} + +/// Escapes a string for use inside TOML basic double-quoted strings. +fn toml_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +/// Normalises timing suffixes on check header lines so snapshots are stable. +/// `[name] 123ms` and `[name] 1.2s` both become `[name] Xms`. +fn normalize_timing(s: &str) -> String { + use regex::Regex; + // Flint check header lines: "[name] 123ms" or "[name] 1.2s" + let re = Regex::new(r"(?m)^(\[[^\]]+\]) \d+(?:\.\d+)?(?:ms|s)$").unwrap(); + let s = re.replace_all(s, "$1 Xms"); + // Biome summary line: "Checked N file(s) in 1234µs. No fixes applied." + let re2 = Regex::new(r"Checked \d+ files? in \d+(?:\.\d+)?(?:µs|ms|s)\.").unwrap(); + re2.replace_all(&s, "Checked N file(s) in Xµs.") + .into_owned() +} + +/// Strips ANSI/VT escape sequences (colour codes, character-set switches, etc.). +/// TOML strings cannot contain raw control characters, so these must be removed. +fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\x1b' { + out.push(c); + continue; + } + match chars.peek().copied() { + Some('[') => { + // CSI sequence: ESC [ + chars.next(); + while let Some(&next) = chars.peek() { + chars.next(); + if next.is_ascii_alphabetic() { + break; + } + } + } + Some(next) if ('\x20'..='\x2f').contains(&next) => { + // Two-byte sequence with intermediate: ESC + // e.g. ESC(B (select ASCII character set) + chars.next(); + while let Some(&next) = chars.peek() { + chars.next(); + if ('\x30'..='\x7e').contains(&next) { + break; + } + } + } + _ => {} // bare ESC — drop it + } + } + out +} + +/// Writes fake binaries from `[fake_bins]` in the test config into `bin_dir`, +/// makes them executable (Unix), and returns a PATH string that prepends +/// `bin_dir` to the current PATH. Returns `None` when no fake_bins are declared. +/// On non-Unix platforms fake_bins are silently ignored. +fn setup_fake_bins(cfg: &toml::Value, case_name: &str, bin_dir: &Path) -> Option { + let table = cfg.get("fake_bins")?.as_table()?; + if table.is_empty() { + return None; + } + + for (bin_name, script) in table { + let content = script + .as_str() + .unwrap_or_else(|| panic!("{case_name}: fake_bins.{bin_name} must be a string")); + let path = bin_dir.join(bin_name); + std::fs::write(&path, content) + .unwrap_or_else(|e| panic!("{case_name}: failed to write fake bin {bin_name}: {e}")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .unwrap_or_else(|e| panic!("{case_name}: chmod failed for {bin_name}: {e}")); + } + } + + let orig = std::env::var("PATH").unwrap_or_default(); + Some(format!("{}:{orig}", bin_dir.display())) +} + +fn copy_dir_into(src: &Path, dst: &Path) { + for entry in std::fs::read_dir(src).expect("files/ dir not found") { + let entry = entry.unwrap(); + let target = dst.join(entry.file_name()); + if entry.path().is_dir() { + std::fs::create_dir_all(&target).unwrap(); + copy_dir_into(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), &target).unwrap(); + } + } +} + +/// Returns the canonical form of the repo path with platform quirks stripped. +/// On macOS this resolves /private/... symlinks. On Windows it strips the \\?\ +/// verbatim prefix so the result matches what tools actually emit. +fn canonical_repo_path(path: &std::path::Path) -> String { + // dunce::canonicalize resolves symlinks (/private/... on macOS) and strips + // the \\?\ verbatim prefix on Windows that tools don't emit. + dunce::canonicalize(path) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +/// Normalises tool output for snapshot comparison: +/// - CRLF → LF +/// - Windows path separators → forward slashes (both output and repo paths) +/// - Strip residual //?/ UNC prefix (after \ normalisation) +/// - Replace canonical and non-canonical repo path forms with +/// - Collapse file:/// → file:// (lychee Windows URI form) +fn normalize_output(s: String, repo_str: &str, repo_canonical: &str) -> String { + let s = s.replace("\r\n", "\n").replace('\r', "\n"); + + #[cfg(windows)] + let (s, canonical_cmp, repo_cmp) = { + // Strip the Windows verbatim UNC prefix \\?\ before substitution. + // Also strip //?/ which appears if the UNC prefix got mixed with forward slashes. + let s = s.replace(r"\\?\", "").replace("//?/", ""); + // Substitute using both backslash and forward-slash forms so paths from + // different tools (shfmt uses \, lychee uses /) are all collapsed. + (s, repo_canonical.to_string(), repo_str.to_string()) + }; + #[cfg(not(windows))] + let (s, canonical_cmp, repo_cmp) = (s, repo_canonical.to_string(), repo_str.to_string()); + + // Substitute using both the canonical form (e.g. long name on Windows, /private/... on + // macOS) and the raw form, in both backslash and forward-slash variants. + let sub = |s: String, pat: &str| -> String { + if pat.is_empty() { + return s; + } + #[cfg(windows)] + let s = s.replace(&pat.replace('\\', "/"), ""); + s.replace(pat, "") + }; + let s = if canonical_cmp != repo_cmp { + sub(s, &canonical_cmp) + } else { + s + }; + let s = sub(s, &repo_cmp); + + // On Windows, normalize backslash path separators to forward slashes. + // Skip content inside single quotes to preserve tool-specific notations + // like dotnet's whitespace descriptions: Insert '\s\s\s\s'. + #[cfg(windows)] + let s = { + let mut out = String::with_capacity(s.len()); + let mut in_single_quote = false; + for ch in s.chars() { + match ch { + '\'' => { + in_single_quote = !in_single_quote; + out.push(ch); + } + '\\' if !in_single_quote => out.push('/'), + other => out.push(other), + } + } + out.replace("file:///", "file://") + }; + s +} diff --git a/tests/test-links.md b/tests/test-links.md index 47920c5..afff07d 100644 --- a/tests/test-links.md +++ b/tests/test-links.md @@ -1,6 +1,6 @@ # Link remap smoke test -These links exercise the GitHub URL remap rules in `tasks/lint/links.sh`. +These links exercise the GitHub URL remap rules in `src/linters/lychee.rs`. On PR branches, lychee rewrites `blob/main/` URLs to the PR branch — these links verify that each remap rule works correctly during CI.