diff --git a/.github/agents/knowledge/linters.md b/.github/agents/knowledge/linters.md index 93b7e6af..39a1cec5 100644 --- a/.github/agents/knowledge/linters.md +++ b/.github/agents/knowledge/linters.md @@ -31,8 +31,8 @@ Available builder modifiers: | `.mise_tool(name)` | Look up availability under a different mise key (e.g. `rust` for `cargo-fmt`) | | `.version_req(range)` | Restrict to a semver range (e.g. `">=1.0.0"`) | | `.excludes(names)` | Skip files already owned by these active checks | -| `.slow()` | Mark as comprehensive-only and skipped by `--fast-only` | -| `.adaptive()` | Mark as comprehensive-only and relevance-gated in `--fast-only` | +| `.slow()` | Mark as comprehensive-only in `flint init` | +| `.adaptive_relevance(fn)` | Skip on local default runs unless the hook reports changed files relevant | | `.linter_config(file, flag)` | Inject a config flag when `FLINT_CONFIG_DIR/` exists (see below) | ## Config File Injection (`.linter_config`) diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 5243b2a9..5a80037c 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -24,8 +24,7 @@ "datasource": "github-tags" }, "google-java-format": { - "packageName": "google-java-format", - "datasource": "github-releases" + "packageName": "google-java-format" }, "hadolint": { "packageName": "hadolint/hadolint", diff --git a/Cargo.toml b/Cargo.toml index a1cadbe5..b62737ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ json5 = "1.0" similar = "3" toml_edit = "0.25" dunce = "1.0.5" +tempfile = "3" [dev-dependencies] -tempfile = "3" regex = "1" diff --git a/README.md b/README.md index b444bd87..3533a71f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ Linter runner built for speed, consistency, and low setup friction: - **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 +- **Local + CI aligned** — one binary, one config model, local defaults tuned + for day-to-day work and broader coverage in CI - **Sensible defaults** — `flint init` scaffolds a working setup quickly, and most repos can stick with the generated defaults - **Opinionated config** — Flint chooses canonical config filenames per linter, @@ -88,6 +89,21 @@ description = "Auto-fix lint issues" run = "flint run --fix" ``` +### Day-to-day use + +Run lints on your changes: + +```bash +mise run lint # check +mise run lint:fix # auto-fix what's fixable +``` + +> [!NOTE] +> In rare cases (currently only `renovate-deps`) a failure may show up +> only in CI. That is a deliberate performance optimization — see +> [adaptive runs](#adaptive-runs). When it happens, flint prints the +> command to reproduce locally (usually `--full` or the linter name). + ### CI setup ```yaml @@ -207,6 +223,24 @@ Click a name in the table below for details. See the +### Adaptive runs + +Some linters are expensive enough that running them on every local +`flint run` would slow the inner loop. For those, `flint run` skips the +linter when none of the changed files could plausibly affect its result. +CI is unaffected — it always runs the full set. + +Affected linters: + +| Linter | Skipped locally when… | +| ------------------------------------------------------------------- | --------------------------------------------------------------- | +| [`renovate-deps`](docs/linters/renovate-deps.md#when-does-this-run) | No change to Renovate config, the snapshot, or any tracked file | + +To force a local run of a skipped linter: + +- `flint run --full` — runs every active linter +- `flint run ` — runs just that one + ## Versioning This project uses [Semantic Versioning](https://semver.org/). diff --git a/docs/alternatives.md b/docs/alternatives.md index fdaf579e..d6294d0a 100644 --- a/docs/alternatives.md +++ b/docs/alternatives.md @@ -8,7 +8,7 @@ the main [why/principles page](why.md). Ratings are relative and intentionally coarse. The sections below explain the "why" behind each row in more detail. -| Tool / approach | Speed | Setup effort | Cross-platform | Cross-language | Autofix support | Delta / diff-aware | Predictable and updatable linter versions | Local == CI | +| Tool / approach | Speed | Setup effort | Cross-platform | Cross-language | Autofix support | Delta / diff-aware | Predictable and updatable linter versions | Local + CI aligned | | ------------------------- | --------------------- | ----------------------------- | --------------- | -------------- | ---------------------- | ------------------ | ----------------------------------------- | ------------------------- | | flint | high | low | yes | yes | yes, where supported | yes | yes | yes | | pre-commit | medium | medium | yes | yes | mixed | mixed | mixed | mixed | @@ -19,7 +19,7 @@ Ratings are relative and intentionally coarse. The sections below explain the Use these sections as relative comparisons against Flint on a few recurring dimensions: speed, setup effort, cross-platform support, cross-language scope, autofix support, delta/diff awareness, predictable and updatable linter -versions, and how closely local behavior matches CI. +versions, and how closely local and CI behavior stay aligned. ## Flint @@ -41,7 +41,7 @@ linter or formatter should govern each domain. | Autofix support | yes, where supported | `flint run --fix` uses each tool's fixer when one exists and reports what still needs review. | | Delta / diff-aware | yes | Changed-file execution is the default model, with baseline expansion only when coverage changes require it. | | Predictable and updatable linter versions | yes | Linter versions are pinned by the repo, so behavior stays stable until the repo intentionally updates to a newer version, for example through Renovate updates to `mise.toml`. | -| Local == CI | yes | The same binary, config model, and pinned tools are used in both environments. | +| Local + CI aligned | yes | The same binary, config model, and pinned tools are used in both environments, with local defaults tuned for changed-file feedback and CI activating the full linter set. | ## pre-commit @@ -66,7 +66,7 @@ lives in hook wiring rather than in a single built-in policy. | Autofix support | mixed | Some hooks fix in place, some only report, and behavior depends on the chosen hooks. | | Delta / diff-aware | mixed | Hook-based runs are often scoped to staged files, but broader CI parity and baseline behavior depend on how each hook is configured. | | Predictable and updatable linter versions | mixed | Hook revisions can be pinned, but version management lives in separate hook configuration instead of flowing through Renovate updates to `mise.toml`. | -| Local == CI | mixed | Teams often use pre-commit locally but a different command or environment in CI. | +| Local + CI aligned | mixed | Teams often use pre-commit locally but a different command or environment in CI. | ## Husky @@ -86,7 +86,7 @@ with no install step and no language runtime dependency. | Autofix support | hook-dependent | Whether fixes are available depends entirely on the commands wired into the hooks. | | Delta / diff-aware | hook-dependent | It can run on changed or staged files, but only if the hook commands are written that way. | | Predictable and updatable linter versions | hook-dependent | Husky only runs whatever commands the repo wires into hooks, so version stability depends on those underlying tools and how the repo manages them. | -| Local == CI | mixed | Husky is usually local-hook infrastructure, while CI often uses separate scripts or commands. | +| Local + CI aligned | mixed | Husky is usually local-hook infrastructure, while CI often uses separate scripts or commands. | ## Spotless and formatter plugins @@ -112,7 +112,7 @@ clean. | Autofix support | yes, formatter-focused | Formatter plugins are usually good at in-place fixes. | | Delta / diff-aware | usually no | They commonly run at project or module scope rather than being natively optimized around changed-file diffs. | | Predictable and updatable linter versions | usually yes in that ecosystem | Build plugins and formatter versions are often pinned through the build system, but the model is tied to that ecosystem rather than being a general lint-runner property. | -| Local == CI | usually yes in that build | Reusing the same build plugin in local and CI is straightforward when the repo already standardizes on that build system. | +| Local + CI aligned | usually yes in that build | Reusing the same build plugin in local and CI is straightforward when the repo already standardizes on that build system. | ## MegaLinter and super-linter @@ -134,4 +134,4 @@ explicit style ownership instead of a broad kitchen-sink layer. | Autofix support | mixed | Some integrated tools can fix in place, but support varies across the bundled linter set and may be awkward in container workflows. | | Delta / diff-aware | limited / mixed | Some support changed-file or PR-oriented modes, but the model is usually broader and less native than a runner built around git diffs. | | Predictable and updatable linter versions | mixed | The wrapper itself is versioned predictably, but the bundled linter set and containerized execution model can still make upgrades feel more indirect. | -| Local == CI | mixed | CI often uses the canonical containerized flow, while local usage may be slower, less common, or configured differently. | +| Local + CI aligned | mixed | CI often uses the canonical containerized flow, while local usage may be slower, less common, or configured differently. | diff --git a/docs/cli.md b/docs/cli.md index f1be8057..bc35d8eb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -18,28 +18,29 @@ it do not need to re-learn the interface. | -------------------- | -------------------------------------------------------------------------------------------------------------------- | | `--fix` | Fix what's fixable, report `clean` / `fixed` / `partial` / `review` outcomes; exit non-zero if anything needs action | | `--full` | Lint all files instead of only changed files | -| `--fast-only` | Skip checks tagged as slow in the registry. 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`, +Every flag has an env var equivalent: `FLINT_FIX`, `FLINT_FULL`, `FLINT_VERBOSE`, `FLINT_SHORT`, `FLINT_NEW_FROM_REV`, `FLINT_TO_REF`. ## Intended use by context | Context | Command | Why | | ---------------------------- | -------------------------------------- | ----------------------------------------------------------------- | -| Interactive development | `flint run` or `flint run --fast-only` | Full output so you can read the details | +| Interactive development | `flint run` | 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 | +| Pre-push hook (CC / agentic) | `flint run --fix` | Fixes what it can silently, surfaces only what needs human review | | CI | `flint run` | Full output for humans reading CI logs | ## Changed-file and baseline runs -By default, `flint run` checks only files changed relative to the merge base. -Use `--full` to check every matching file explicitly. +By default, local `flint run` checks linters triggered by changes relative to +the merge base. In CI, `flint run` activates the full linter set while still +keeping diff-aware scoping where each linter supports it. Use `--full` to +check every matching file explicitly. Some changed-file runs intentionally expand one or more affected checks to all matching files. This establishes a baseline when lint coverage changes, while diff --git a/docs/linters.md b/docs/linters.md index 59e60f35..2edcfa7b 100644 --- a/docs/linters.md +++ b/docs/linters.md @@ -220,7 +220,7 @@ check_all_local = true | Binary | `renovate` | | Scope | [native](#scope-native) | | Patterns | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | -| Run policy | adaptive — runs in `--fast-only` only when relevant | +| Run policy | adaptive — see [when does this run?](linters/renovate-deps.md#when-does-this-run) | Verifies `renovate-tracked-deps.json` next to the active Renovate config is up to date by running Renovate locally and comparing its @@ -382,14 +382,6 @@ whole project when it does run. `golangci-lint` is the exception — it uses Implemented in-process rather than via a command template. These checks may run without file arguments or use custom orchestration logic. -Checks use one of three run policies: - -- `fast` — always runs, including in `--fast-only` -- `slow` — skipped by `--fast-only` -- `adaptive` — runs in `--fast-only` only when the changed files are relevant - -Use `--fast-only` for local/pre-push feedback and the full set in CI. - **`editorconfig-checker` defers to formatters**: `editorconfig-checker` runs on all files, but automatically skips file types owned by an active formatter. If none of those formatters are installed, `editorconfig-checker` checks those diff --git a/docs/linters/renovate-deps.md b/docs/linters/renovate-deps.md index 2fb7fe96..b1943844 100644 --- a/docs/linters/renovate-deps.md +++ b/docs/linters/renovate-deps.md @@ -10,6 +10,23 @@ The second check is there to catch configuration mistakes before they show up as separate Renovate PRs or README drift. +## When does this run? + +CI always runs `renovate-deps`. Locally `flint run` only runs it when the +changed files plausibly affect the snapshot. `--full` or naming the +linter explicitly bypass the skip. + +| Change | Local | CI | +| --------------------------------------------- | ----- | --- | +| Renovate config edited | ✅ | ✅ | +| `renovate-tracked-deps.json` snapshot edited | ✅ | ✅ | +| File already tracked in the snapshot edited | ✅ | ✅ | +| New tool/action added that is not yet tracked | ❌ | ✅ | +| Unrelated change (docs, source, etc.) | ❌ | ✅ | + +The "new tool not yet tracked" case is the typical reason a CI failure +won't reproduce locally without `--full`. + ## What it catches Goal: `mise.toml` and `README.md` both refer to actionlint, so you want @@ -100,13 +117,16 @@ If the snapshot is stale: flint run --fix renovate-deps ``` -If you want to force a fresh metadata rebuild instead of reusing any existing -committed metadata for the same dependency names, for example after changing Renovate -grouping config or while debugging suspicious `meta` entries: +Verification (plain `flint run`) uses Renovate's cheap `--dry-run=extract` +plus the committed snapshot's metadata. `--fix` regenerates via +`--dry-run=lookup` so meta is authoritative. -```bash -FLINT_RENOVATE_DEPS_REFRESH_META=1 flint run --fix renovate-deps -``` +The linter requires every dep referenced by a `packageRule` to have +`packageName`; deps matched via `matchPackageNames` additionally require +`datasource` so Renovate's `(packageName, datasource)` grouping is +deterministic. `matchDepNames` rules don't require datasource — bare-key +mise tools like `biome` don't always surface one even in lookup-mode +output, and Renovate matches them by name regardless. If rule coverage is inconsistent: diff --git a/docs/why.md b/docs/why.md index b4ad9644..ac392895 100644 --- a/docs/why.md +++ b/docs/why.md @@ -19,12 +19,14 @@ This is the primary goal; everything else serves it. - Small binary, cached by mise - Diff-aware by default: changed files only unless `--full` is requested - Opt-in activation: undeclared tools are skipped entirely -- Slow checks can be skipped via `--fast-only` +- Local runs skip slower checks by default unless you use `--full` or name the + linter explicitly -## Local same as CI +## Local and CI stay aligned -One binary, one config model, identical behavior. There is no "native mode -subset" distinction. If it passes locally, it passes in CI. +One binary, one config model, and the same pinned tools in both environments. +Local runs default to the change-triggered subset for day-to-day speed, while +CI activates the full linter set. ## Predictable and updatable linter versions diff --git a/src/config.rs b/src/config.rs index 5f5bcc1a..5d2b4a41 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,8 +56,6 @@ pub struct LycheeConfig { pub struct RenovateDepsConfig { // Env var: FLINT_RENOVATE_DEPS_EXCLUDE_MANAGERS (JSON array, e.g. '["npm"]') pub exclude_managers: Vec, - // Env var: FLINT_RENOVATE_DEPS_REFRESH_META - pub refresh_meta: bool, } #[derive(Debug, Deserialize, Clone)] diff --git a/src/hook.rs b/src/hook.rs index 052032ab..87cc72c1 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -4,7 +4,7 @@ use std::process::Command; 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"; +mise exec -- flint run --fix\n"; /// Returns the repository-local pre-commit hook path for this git checkout. pub(crate) fn pre_commit_path(project_root: &Path) -> Result { diff --git a/src/init/scaffold.rs b/src/init/scaffold.rs index 2497948e..4abb70bb 100644 --- a/src/init/scaffold.rs +++ b/src/init/scaffold.rs @@ -182,9 +182,7 @@ pub(super) fn maybe_install_hook(project_root: &Path, yes: bool) -> Result<()> { let install = if yes { true } else { - print!( - "Install pre-commit hook (runs `flint run --fix --fast-only` before each commit)? [Y/n] " - ); + print!("Install pre-commit hook (runs `flint run --fix` before each commit)? [Y/n] "); io::stdout().flush()?; let mut input = String::new(); io::stdin().lock().read_line(&mut input)?; diff --git a/src/init/tests.rs b/src/init/tests.rs index 01fdd8f6..09a0ab8c 100644 --- a/src/init/tests.rs +++ b/src/init/tests.rs @@ -946,7 +946,6 @@ fn apply_env_and_tasks_adds_sections() { assert!(content.contains("FLINT_CONFIG_DIR = \".github/config\"")); assert!(content.contains("flint run")); assert!(content.contains("flint run --fix")); - assert!(!content.contains("--fast-only")); } #[test] @@ -955,7 +954,6 @@ fn apply_env_and_tasks_does_not_add_pre_commit_task_when_slow() { 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")); } diff --git a/src/init/ui.rs b/src/init/ui.rs index 67b0b14f..a020ff22 100644 --- a/src/init/ui.rs +++ b/src/init/ui.rs @@ -237,10 +237,12 @@ fn print_linter_table( "[ ]" }; let cursor_mark = if flat_idx == cursor { ">" } else { " " }; - let speed = match check.run_policy { - crate::registry::RunPolicy::Fast => "fast", - crate::registry::RunPolicy::Slow => "slow", - crate::registry::RunPolicy::Adaptive => "adaptive", + let speed = if check.adaptive_relevance.is_some() { + "adaptive" + } else if check.category == crate::registry::Category::Slow { + "slow" + } else { + "fast" }; let patterns = check.patterns.join(" "); write!( diff --git a/src/linters/renovate_deps/install_patch.rs b/src/linters/renovate_deps/install_patch.rs new file mode 100644 index 00000000..eef36665 --- /dev/null +++ b/src/linters/renovate_deps/install_patch.rs @@ -0,0 +1,128 @@ +use anyhow::Context; +use dunce::canonicalize; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +const FLINT_LOADER_FILE: &str = "loader.mjs"; +const FLINT_REGISTER_FILE: &str = "register.mjs"; +pub(crate) const RENOVATE_LOCAL_PATCH_SENTINEL: &str = r#"dryRun: _params.dryRun ?? "lookup","#; +pub(crate) const RENOVATE_LOCAL_PATCH_REGEX: &str = r#"^(?\s*)dryRun:\s*"lookup",\s*$"#; + +pub(crate) fn configure_extract_workaround_env( + env: &mut Vec<(String, String)>, + dry_run: &str, +) -> anyhow::Result<()> { + if dry_run != "extract" { + return Ok(()); + } + + // Temporary workaround until the upstream Renovate fix is released: + // https://github.com/renovatebot/renovate/pull/43129 + let register_path = register_path()?; + append_node_import(env, register_path); + Ok(()) +} + +// One private temp dir per flint process, lazily created. tempfile randomizes +// the directory name and creates it with `O_EXCL`, so a local attacker can't +// pre-create a symlink at the path we'll write to. +fn register_path() -> anyhow::Result<&'static Path> { + static REGISTER_PATH: OnceLock = OnceLock::new(); + if let Some(path) = REGISTER_PATH.get() { + return Ok(path); + } + let path = create_loader_files()?; + Ok(REGISTER_PATH.get_or_init(|| path)) +} + +fn create_loader_files() -> anyhow::Result { + let dir = tempfile::Builder::new() + .prefix("flint-renovate-loader-") + .tempdir() + .context("failed to create renovate loader temp dir")? + .keep(); + + let loader_path = dir.join(FLINT_LOADER_FILE); + let register_path = dir.join(FLINT_REGISTER_FILE); + std::fs::write(&loader_path, loader_source()) + .with_context(|| format!("failed to write {}", loader_path.display()))?; + std::fs::write(®ister_path, register_source(&loader_path)) + .with_context(|| format!("failed to write {}", register_path.display()))?; + + Ok(register_path) +} + +fn append_node_import(env: &mut Vec<(String, String)>, register_path: &Path) { + let import_opt = format!("--import={}", file_url(register_path)); + let Some((_, node_options)) = env.iter_mut().find(|(key, _)| key == "NODE_OPTIONS") else { + env.push(("NODE_OPTIONS".into(), import_opt)); + return; + }; + + if node_options.split_whitespace().any(|opt| opt == import_opt) { + return; + } + + if node_options.is_empty() { + *node_options = import_opt; + } else { + node_options.push(' '); + node_options.push_str(&import_opt); + } +} + +fn loader_source() -> String { + format!( + "const targetSuffix = \"/dist/modules/platform/local/index.js\";\n\ +const patchSentinel = {};\n\ +const patchRegex = /{}/m;\n\ +\n\ +export async function load(url, context, defaultLoad) {{\n\ + const result = await defaultLoad(url, context, defaultLoad);\n\ + if (!url.endsWith(targetSuffix)) return result;\n\ +\n\ + const source = typeof result.source === \"string\"\n\ + ? result.source\n\ + : Buffer.from(result.source).toString(\"utf8\");\n\ + if (source.includes(patchSentinel)) return result;\n\ +\n\ + const patched = source.replace(patchRegex, '$dryRun: _params.dryRun ?? \"lookup\",');\n\ + return {{ ...result, source: patched }};\n\ +}}\n", + serde_json::to_string(RENOVATE_LOCAL_PATCH_SENTINEL).unwrap(), + RENOVATE_LOCAL_PATCH_REGEX, + ) +} + +fn file_url(path: &Path) -> String { + let absolute = canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let mut raw = absolute.to_string_lossy().replace('\\', "/"); + if let Some(stripped) = raw.strip_prefix("//?/UNC/") { + raw = format!("//{stripped}"); + } else if let Some(stripped) = raw.strip_prefix("//?/") { + raw = stripped.to_string(); + } + let mut out = String::from("file://"); + if !raw.starts_with('/') { + out.push('/'); + } + for b in raw.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b':' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +fn register_source(loader_path: &Path) -> String { + format!( + "import {{ register }} from 'node:module';\n\ +import {{ pathToFileURL }} from 'node:url';\n\ +\n\ +register(pathToFileURL({}).href, import.meta.url);\n", + serde_json::to_string(&loader_path.to_string_lossy().to_string()).unwrap(), + ) +} diff --git a/src/linters/renovate_deps/mod.rs b/src/linters/renovate_deps/mod.rs index c6fae464..e7c171fb 100644 --- a/src/linters/renovate_deps/mod.rs +++ b/src/linters/renovate_deps/mod.rs @@ -3,8 +3,10 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::process::Stdio; +use self::install_patch::configure_extract_workaround_env; use self::rules::{ - comparable_package_rules_for_config, trim_snapshot_meta, validate_rule_coverage, + comparable_package_rules_for_config, incomplete_meta_for_rules, trim_snapshot_meta, + validate_rule_coverage, }; use self::snapshot::{Snapshot, extract_deps, read_snapshot, unified_diff, write_snapshot}; use crate::config::RenovateDepsConfig; @@ -16,6 +18,7 @@ use crate::registry::{ NativeRunContext, NativeRunFuture, PreparedNativeCheck, }; +mod install_patch; mod rules; mod snapshot; @@ -67,18 +70,24 @@ impl PreparedNativeCheck for PreparedRenovateDeps { fn run(self: Box, ctx: NativeRunContext) -> NativeRunFuture { Box::pin(async move { - crate::linters::renovate_deps::run(&self.cfg, ctx.fix, &ctx.project_root).await + crate::linters::renovate_deps::run(&self.cfg, ctx.fix, ctx.verbose, &ctx.project_root) + .await }) } } -pub async fn run(cfg: &RenovateDepsConfig, fix: bool, project_root: &Path) -> LinterOutput { +pub async fn run( + cfg: &RenovateDepsConfig, + fix: bool, + verbose: bool, + project_root: &Path, +) -> LinterOutput { match validate_runtime_env() { Ok(Some(warning)) => eprintln!("{warning}"), Ok(None) => {} Err(stderr) => return LinterOutput::err(stderr), } - match run_inner(cfg, fix, project_root).await { + match run_inner(cfg, fix, verbose, project_root).await { Ok(out) => out, Err(e) => LinterOutput::err(format!("flint: renovate-deps: {e}\n")), } @@ -393,6 +402,7 @@ fn add_to_extends(content: &str, entry: &str) -> anyhow::Result { async fn run_inner( cfg: &RenovateDepsConfig, fix: bool, + verbose: bool, project_root: &Path, ) -> anyhow::Result { let config_path = resolve_renovate_config_path(project_root)?; @@ -401,50 +411,43 @@ async fn run_inner( let skipped_rule_notes = parsed_rules.skipped_notes; 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 = Snapshot::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; - } - let committed = if committed_path.exists() { Some(read_snapshot(&std::fs::read_to_string(&committed_path)?)?) } else { None }; - maybe_reuse_committed_meta(&mut generated, committed.as_ref(), cfg.refresh_meta); - - validate_rule_coverage(&generated, &rules)?; - trim_snapshot_meta(&mut generated, &rules); + // Verification path uses cheap extract + committed meta. Fix mode + // unconditionally regenerates via lookup so the written snapshot carries + // authoritative packageName/datasource metadata for every dep — extract + // alone leaves gaps (e.g. bare-key mise tools resolved through aqua). + let dry_run = if fix { "lookup" } else { "extract" }; + if verbose && fix { + eprintln!("flint: renovate-deps: regenerating snapshot via lookup"); + } + let mut generated = + generate_snapshot(project_root, &config_path, &cfg.exclude_managers, dry_run).await?; + if !fix { + maybe_reuse_committed_meta(&mut generated, committed.as_ref()); + } - if committed.is_none() { + if let Some(reason) = incomplete_meta_for_rules(&generated, &rules) { if fix { - write_snapshot(&committed_path, &generated)?; - let mut stdout = notes_output(&skipped_rule_notes).into_bytes(); - stdout.extend_from_slice(format!("{COMMITTED_FILE} has been created.\n").as_bytes()); - return Ok(LinterOutput { - ok: true, - stdout, - stderr: vec![], - setup_outcome: None, - }); + anyhow::bail!( + "lookup did not populate required metadata: {reason}.\nThis is a renovate-deps bug — please report." + ); } - return Ok(LinterOutput::err(format!( - "ERROR: {committed_display} does not exist.\nRun `flint run --fix renovate-deps` to create it.\n" - ))); + anyhow::bail!( + "dependency metadata is out of date: {reason}.\nRun `flint run --fix renovate-deps` to refresh metadata." + ); } - let committed = committed.expect("checked above"); + validate_rule_coverage(&generated, &rules)?; + trim_snapshot_meta(&mut generated, &rules); - if committed == generated { + if let Some(committed) = committed.as_ref() + && *committed == generated + { let mut stdout = notes_output(&skipped_rule_notes).into_bytes(); stdout.extend_from_slice(format!("{COMMITTED_FILE} is up to date.\n").as_bytes()); return Ok(LinterOutput { @@ -455,28 +458,38 @@ async fn run_inner( }); } - let diff = unified_diff(&committed, &generated, &committed_display); - - if fix { - write_snapshot(&committed_path, &generated)?; - let mut stdout = notes_output(&skipped_rule_notes).into_bytes(); - stdout.extend_from_slice(diff.as_bytes()); - stdout.extend_from_slice(format!("{COMMITTED_FILE} has been updated.\n").as_bytes()); - return Ok(LinterOutput { - ok: true, - stdout, - stderr: vec![], - setup_outcome: None, - }); + if !fix { + let message = match committed.as_ref() { + Some(committed) => format!( + "{}ERROR: {COMMITTED_FILE} is out of date.\nRun `flint run --fix renovate-deps` to update.\n", + unified_diff(committed, &generated, &committed_display), + ), + None => format!( + "ERROR: {committed_display} does not exist.\nRun `flint run --fix renovate-deps` to create it.\n" + ), + }; + return Ok(LinterOutput::err(message)); } + write_snapshot(&committed_path, &generated)?; + + let mut stdout = notes_output(&skipped_rule_notes).into_bytes(); + let (diff, status) = match committed.as_ref() { + Some(committed) => ( + unified_diff(committed, &generated, &committed_display), + format!("{COMMITTED_FILE} has been updated.\n"), + ), + None => ( + String::new(), + format!("{COMMITTED_FILE} has been created.\n"), + ), + }; + stdout.extend_from_slice(diff.as_bytes()); + stdout.extend_from_slice(status.as_bytes()); 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(), + ok: true, + stdout, + stderr: vec![], setup_outcome: None, }) } @@ -489,10 +502,35 @@ fn notes_output(notes: &[String]) -> String { format!("{}\n", notes.join("\n")) } +async fn generate_snapshot( + project_root: &Path, + config_path: &Path, + exclude_managers: &[String], + dry_run: &str, +) -> anyhow::Result { + // Renovate occasionally produces empty packageFiles on the first run (transient + // package cache, registry, or startup issue). Retry up to 3 times with a short delay. + let mut generated = Snapshot::default(); + for attempt in 1..=3u32 { + let log_bytes = run_renovate(project_root, config_path, dry_run).await?; + generated = extract_deps(&log_bytes, exclude_managers)?; + if !generated.is_empty() || attempt == 3 { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } + Ok(generated) +} + /// Runs `renovate --platform=local` and returns the combined stdout+stderr log bytes. -async fn run_renovate(project_root: &Path, config_path: &Path) -> anyhow::Result> { +async fn run_renovate( + project_root: &Path, + config_path: &Path, + dry_run: &str, +) -> anyhow::Result> { // Forward env, setting Renovate-specific vars. let mut env: Vec<(String, String)> = std::env::vars().collect(); + configure_extract_workaround_env(&mut env, dry_run)?; // 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())); @@ -517,7 +555,7 @@ async fn run_renovate(project_root: &Path, config_path: &Path) -> anyhow::Result "renovate".to_string(), "--platform=local".to_string(), "--require-config=ignored".to_string(), - "--dry-run=extract".to_string(), + format!("--dry-run={dry_run}"), ], false, ) @@ -545,7 +583,6 @@ async fn run_renovate(project_root: &Path, config_path: &Path) -> anyhow::Result Ok(combined) } - fn resolve_renovate_config_path(project_root: &Path) -> anyhow::Result { RENOVATE_CONFIG_PATTERNS .iter() @@ -584,14 +621,8 @@ fn merge_missing_meta_from_committed(generated: &mut Snapshot, committed: &Snaps } } -fn maybe_reuse_committed_meta( - generated: &mut Snapshot, - committed: Option<&Snapshot>, - refresh_meta: bool, -) { - if let Some(committed) = committed - && !refresh_meta - { +fn maybe_reuse_committed_meta(generated: &mut Snapshot, committed: Option<&Snapshot>) { + if let Some(committed) = committed { merge_missing_meta_from_committed(generated, committed); } } diff --git a/src/linters/renovate_deps/rules.rs b/src/linters/renovate_deps/rules.rs index 2cb863c2..7c100929 100644 --- a/src/linters/renovate_deps/rules.rs +++ b/src/linters/renovate_deps/rules.rs @@ -129,6 +129,98 @@ pub(crate) fn trim_snapshot_meta(snapshot: &mut Snapshot, rules: &[ComparablePac .retain(|dep_name, _| relevant.contains(dep_name)); } +/// Returns the first dep whose meta is incomplete relative to the configured +/// package rules: +/// +/// - any rule that references the dep requires `packageName` +/// - rules using `matchPackageNames` additionally require `datasource` (so +/// Renovate's `(packageName, datasource)` grouping is deterministic) +/// +/// `matchDepNames` rules don't need `datasource` — Renovate matches deps by +/// name there, and bare-key mise tools like `biome` don't always surface a +/// datasource even in lookup-mode output. +pub(crate) fn incomplete_meta_for_rules( + snapshot: &Snapshot, + rules: &[ComparablePackageRule], +) -> Option { + if rules.is_empty() { + return None; + } + + for dep_name in extracted_dep_names(snapshot) { + let meta = snapshot.meta.get(&dep_name); + let package_name = meta.and_then(|m| m.package_name.as_deref()); + let datasource = meta.and_then(|m| m.datasource.as_deref()); + + for rule in rules { + let (label, needs_datasource) = match &rule.matcher { + RuleMatcher::DepNames(names) if names.contains(&dep_name) => { + (format!("matchDepNames in {}", rule.label), false) + } + RuleMatcher::PackageNames(names) + if package_name_rule_dep_candidates(snapshot, names).contains(&dep_name) => + { + let packages = names.iter().cloned().collect::>().join(", "); + ( + format!("matchPackageNames [{packages}] in {}", rule.label), + true, + ) + } + _ => continue, + }; + if package_name.is_none() { + return Some(format!( + "dep {dep_name:?} is matched by {label} but is missing packageName" + )); + } + if needs_datasource && datasource.is_none() { + return Some(format!( + "dep {dep_name:?} is matched by {label} but is missing datasource" + )); + } + } + } + + None +} + +fn extracted_dep_names(snapshot: &Snapshot) -> BTreeSet { + snapshot + .files + .values() + .flat_map(|managers| managers.values()) + .flatten() + .cloned() + .collect() +} + +fn package_name_rule_dep_candidates( + snapshot: &Snapshot, + package_names: &BTreeSet, +) -> BTreeSet { + let mut candidates: BTreeSet<_> = snapshot + .meta + .iter() + .filter_map(|(dep_name, meta)| { + meta.package_name + .as_deref() + .filter(|package_name| package_names.contains(*package_name)) + .map(|_| dep_name.clone()) + }) + .collect(); + + if candidates.is_empty() { + candidates.extend( + package_names + .iter() + .filter_map(|package_name| package_name.rsplit('/').next()) + .map(ToOwned::to_owned), + ); + } + + candidates +} + enum ComparableRuleOutcome { Comparable(ComparablePackageRule), Skipped { note: String }, diff --git a/src/linters/renovate_deps/tests.rs b/src/linters/renovate_deps/tests.rs index 9ca15341..33ca0f8b 100644 --- a/src/linters/renovate_deps/tests.rs +++ b/src/linters/renovate_deps/tests.rs @@ -1,4 +1,7 @@ -use super::rules::{ComparablePackageRule, RuleMatcher, relevant_dep_names}; +use super::install_patch::configure_extract_workaround_env; +use super::rules::{ + ComparablePackageRule, RuleMatcher, incomplete_meta_for_rules, relevant_dep_names, +}; use super::snapshot::{DepFiles, DepMeta}; use super::*; use std::collections::BTreeSet; @@ -98,6 +101,37 @@ fn configure_renovate_deps_keeps_existing_managers() { assert!(!result.contains("github-actions")); } +#[test] +fn configure_extract_workaround_env_adds_node_import() { + let mut env = vec![]; + + configure_extract_workaround_env(&mut env, "extract").unwrap(); + + let node_options = env + .iter() + .find(|(key, _)| key == "NODE_OPTIONS") + .map(|(_, value)| value) + .unwrap(); + assert!(node_options.contains("--import=")); + assert!(node_options.contains("file://")); +} + +#[test] +fn configure_extract_workaround_env_preserves_existing_node_options() { + let mut env = vec![("NODE_OPTIONS".to_string(), "--trace-warnings".to_string())]; + + configure_extract_workaround_env(&mut env, "extract").unwrap(); + + let node_options = env + .iter() + .find(|(key, _)| key == "NODE_OPTIONS") + .map(|(_, value)| value) + .unwrap(); + assert!(node_options.contains("--trace-warnings")); + assert!(node_options.contains("--import=")); + assert!(node_options.contains("file://")); +} + #[test] fn replaces_unpinned_flint_entry_in_place() { let input = r#"{ extends: ["config:recommended", "github>grafana/flint"] }"#; @@ -410,7 +444,7 @@ fn merge_missing_meta_from_committed_keeps_existing_details() { } #[test] -fn maybe_reuse_committed_meta_merges_when_refresh_meta_is_disabled() { +fn maybe_reuse_committed_meta_merges_missing_fields() { let mut generated = snapshot( &[("actionlint", None, Some("github-releases"))], &[("mise.toml", &[("mise", &["actionlint"])])], @@ -424,7 +458,7 @@ fn maybe_reuse_committed_meta_merges_when_refresh_meta_is_disabled() { &[("mise.toml", &[("mise", &["actionlint"])])], ); - maybe_reuse_committed_meta(&mut generated, Some(&committed), false); + maybe_reuse_committed_meta(&mut generated, Some(&committed)); assert_eq!( generated.meta["actionlint"].package_name.as_deref(), @@ -433,12 +467,8 @@ fn maybe_reuse_committed_meta_merges_when_refresh_meta_is_disabled() { } #[test] -fn maybe_reuse_committed_meta_skips_merge_when_refresh_meta_is_enabled() { - let mut generated = snapshot( - &[("actionlint", None, Some("github-releases"))], - &[("mise.toml", &[("mise", &["actionlint"])])], - ); - let committed = snapshot( +fn incomplete_meta_for_rules_passes_when_meta_is_complete() { + let snap = snapshot( &[( "actionlint", Some("rhysd/actionlint"), @@ -446,10 +476,56 @@ fn maybe_reuse_committed_meta_skips_merge_when_refresh_meta_is_enabled() { )], &[("mise.toml", &[("mise", &["actionlint"])])], ); + let rules = vec![ComparablePackageRule { + label: "group \"linters\"".to_string(), + matcher: RuleMatcher::DepNames(BTreeSet::from(["actionlint".to_string()])), + }]; + assert!(incomplete_meta_for_rules(&snap, &rules).is_none()); +} + +#[test] +fn incomplete_meta_for_rules_dep_name_rule_tolerates_missing_datasource() { + // matchDepNames doesn't need datasource — Renovate doesn't always surface + // one for bare-key mise tools (e.g. biome) and grouping isn't affected. + let snap = snapshot( + &[("biome", Some("biome"), None)], + &[("mise.toml", &[("mise", &["biome"])])], + ); + let rules = vec![ComparablePackageRule { + label: "group \"linters\"".to_string(), + matcher: RuleMatcher::DepNames(BTreeSet::from(["biome".to_string()])), + }]; + assert!(incomplete_meta_for_rules(&snap, &rules).is_none()); +} - maybe_reuse_committed_meta(&mut generated, Some(&committed), true); +#[test] +fn incomplete_meta_for_rules_dep_name_rule_flags_missing_packagename() { + let snap = snapshot( + &[("actionlint", None, Some("github-releases"))], + &[("mise.toml", &[("mise", &["actionlint"])])], + ); + let rules = vec![ComparablePackageRule { + label: "group \"linters\"".to_string(), + matcher: RuleMatcher::DepNames(BTreeSet::from(["actionlint".to_string()])), + }]; + let reason = incomplete_meta_for_rules(&snap, &rules).unwrap(); + assert!(reason.contains("actionlint")); + assert!(reason.contains("packageName")); +} - assert_eq!(generated.meta["actionlint"].package_name, None); +#[test] +fn incomplete_meta_for_rules_package_name_rule_requires_datasource() { + let snap = snapshot( + &[("mise", Some("jdx/mise"), None)], + &[("mise.toml", &[("mise", &["mise"])])], + ); + let rules = vec![ComparablePackageRule { + label: "group \"mise\"".to_string(), + matcher: RuleMatcher::PackageNames(BTreeSet::from(["jdx/mise".to_string()])), + }]; + let reason = incomplete_meta_for_rules(&snap, &rules).unwrap(); + assert!(reason.contains("mise")); + assert!(reason.contains("datasource")); } #[test] diff --git a/src/main.rs b/src/main.rs index 3ba39a96..194ceb02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,8 +9,8 @@ mod setup; use anyhow::Result; use clap::{Args, Parser, Subcommand}; -use registry::{CheckKind, FixBehavior, LinterConfig, RunPolicy, Scope}; -use runner::{CheckResult, RunOptions}; +use registry::{CheckKind, FixBehavior, LinterConfig, Scope}; +use runner::{CheckResult, RunContext as RunnerRunContext, RunOptions}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -44,7 +44,7 @@ struct HookArgs { #[derive(Subcommand, Debug)] enum HookCommand { - /// Install a pre-commit hook that runs `flint run --fix --fast-only`. + /// Install a pre-commit hook that runs `flint run --fix`. Install, } @@ -87,10 +87,6 @@ struct RunArgs { #[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, @@ -112,10 +108,30 @@ struct RunArgs { #[arg(long, env = "FLINT_TIME")] time: bool, - /// Linters to run (default: all discovered). Explicit linters override --fast-only. + /// Linters to run (default: all discovered). + /// Explicit names bypass the local relevance gate. linters: Vec, } +impl From<&RunArgs> for FixSummaryOptions { + fn from(args: &RunArgs) -> Self { + Self { + allow_fixed: args.allow_fixed, + short: args.short, + verbose: args.verbose, + time: args.time, + } + } +} + +fn use_filtered_run_policy(args: &RunArgs, explicit: bool, is_ci: bool) -> bool { + if explicit || args.full { + return false; + } + + !is_ci +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -177,7 +193,7 @@ async fn run( 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). + // Explicit linter names bypass filtered local defaults (same behaviour as golangci-lint). let explicit = !args.linters.is_empty(); let checks: Vec<®istry::Check> = if explicit { let mut out = vec![]; @@ -203,10 +219,14 @@ async fn run( args.to_ref.as_deref(), )?; - // Discover which checks are declared in the consuming repo's mise.toml, and apply - // --fast-only policy (skipped when linters are named explicitly, relevance-gated for - // adaptive checks). mise guarantees declared tools are on PATH, so no PATH check needed. + // Discover which checks are declared in the consuming repo's mise.toml. + // Outside CI, plain `flint run` relevance-gates checks that declare an + // `adaptive_relevance` hook. Explicit linter names and `--full` bypass the + // gate; CI always runs the full set. + // mise guarantees declared tools are on PATH, so no PATH check needed. let mise_tools = registry::read_mise_tools(project_root); + let is_ci = linters::env::is_ci_from(|name| std::env::var(name).ok()); + let use_filtered_policy = use_filtered_run_policy(&args, explicit, is_ci); let flint_setup_selected = checks.iter().any(|c| c.kind.is_setup()); if !flint_setup_selected { if let Some((old, new)) = registry::find_obsolete_key(&mise_tools) { @@ -229,17 +249,8 @@ async fn run( }; for c in checks { if registry::check_active(c, &mise_tools) { - let include = if explicit || !args.fast_only { - true - } else { - match c.run_policy { - RunPolicy::Fast => true, - RunPolicy::Slow => false, - RunPolicy::Adaptive => { - c.adaptive_relevance.is_none_or(|hook| hook(&relevance_ctx)) - } - } - }; + let include = !use_filtered_policy + || c.adaptive_relevance.is_none_or(|hook| hook(&relevance_ctx)); if include { out.push(c); } @@ -269,7 +280,7 @@ async fn run( short: args.short, time: args.time, }, - RunContext { + ExecutionContext { active_checks: &active, project_root, cfg: &cfg, @@ -290,7 +301,7 @@ async fn run( FixOutcome::Partial(_) | FixOutcome::Review(_) ) { - finish_fix_outcomes(vec![setup_outcome], args.allow_fixed); + finish_fix_outcomes(vec![setup_outcome], (&args).into()); return Ok(()); } else { setup_fix_outcome = Some(setup_outcome); @@ -319,7 +330,7 @@ async fn run( if active.is_empty() { if let Some(outcome) = setup_fix_outcome { - finish_fix_outcomes(vec![outcome], args.allow_fixed); + finish_fix_outcomes(vec![outcome], (&args).into()); } if let Some(setup_result) = setup_check_result { finish_check_results(vec![setup_result], &active, args.short); @@ -364,7 +375,7 @@ async fn run( } else { Some(files::all(project_root, &cfg)?) }; - let run_ctx = RunContext { + let run_ctx = ExecutionContext { active_checks: &active, project_root, cfg: &cfg, @@ -380,6 +391,7 @@ async fn run( .copied() .partition(|c| supports_single_pass_fix(c)); + let fix_summary: FixSummaryOptions = (&args).into(); let mut outcomes = setup_fix_outcome.into_iter().collect::>(); if !legacy_checks.is_empty() { @@ -398,6 +410,14 @@ async fn run( ) .await?; + outcomes.extend( + check_results + .iter() + .filter(|r| r.ok) + .cloned() + .map(FixOutcome::Clean), + ); + let (fixable, reviewable): (Vec, Vec) = check_results .into_iter() .filter(|r| !r.ok) @@ -431,8 +451,10 @@ async fn run( if let Some(check) = legacy_checks.iter().find(|c| c.name == r.name) { if check.fix_behavior() == registry::FixBehavior::PartialNeedsVerify { to_verify.push(r.name); + } else if matches!(check.kind, CheckKind::Native(_)) { + outcomes.push(classify_single_pass_fix(r)); } else { - outcomes.push(FixOutcome::Fixed(r.name)); + outcomes.push(FixOutcome::Fixed(r)); } } } else { @@ -463,7 +485,7 @@ async fn run( .await?; for r in verify_results { if r.ok { - outcomes.push(FixOutcome::Fixed(r.name)); + outcomes.push(FixOutcome::Fixed(r)); } else { outcomes.push(FixOutcome::Partial(r)); } @@ -491,7 +513,7 @@ async fn run( } } - finish_fix_outcomes(outcomes, args.allow_fixed); + finish_fix_outcomes(outcomes, fix_summary); return Ok(()); } @@ -519,13 +541,21 @@ async fn run( } #[derive(Clone, Copy)] -struct RunContext<'a> { +struct ExecutionContext<'a> { active_checks: &'a [&'a registry::Check], project_root: &'a Path, cfg: &'a config::Config, config_dir: &'a Path, } +#[derive(Clone, Copy)] +struct FixSummaryOptions { + allow_fixed: bool, + short: bool, + verbose: bool, + time: bool, +} + struct AdaptiveRunContext<'a> { file_list: &'a files::FileList, project_root: &'a Path, @@ -552,8 +582,8 @@ impl registry::StatusContext for LinterStatusContext<'_> { } enum FixOutcome { - Clean, - Fixed(String), + Clean(CheckResult), + Fixed(CheckResult), Partial(CheckResult), Review(CheckResult), } @@ -561,22 +591,38 @@ enum FixOutcome { impl FixOutcome { fn result(&self) -> Option<&CheckResult> { match self { - Self::Partial(result) | Self::Review(result) => Some(result), - Self::Clean | Self::Fixed(_) => None, + Self::Clean(result) + | Self::Fixed(result) + | Self::Partial(result) + | Self::Review(result) => Some(result), } } } -fn finish_fix_outcomes(outcomes: Vec, allow_fixed: bool) { - // Emit linter output for checks that need manual review so the caller has - // the failure details without a second flint invocation. - for r in outcomes.iter().filter_map(FixOutcome::result) { - 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)); +fn finish_fix_outcomes(outcomes: Vec, opts: FixSummaryOptions) { + let FixSummaryOptions { + allow_fixed, + short, + verbose, + time, + } = opts; + if !short { + for r in outcomes.iter().filter_map(FixOutcome::result) { + if verbose || !r.ok || time { + eprintln!( + "[{}]{}", + r.name, + runner::format_duration_suffix(time, r.duration) + ); + } + if verbose || !r.ok { + if !r.stdout.is_empty() { + eprint!("{}", String::from_utf8_lossy(&r.stdout)); + } + if !r.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&r.stderr)); + } + } } } @@ -585,8 +631,8 @@ fn finish_fix_outcomes(outcomes: Vec, allow_fixed: bool) { let mut review = vec![]; for outcome in outcomes { match outcome { - FixOutcome::Clean => {} - FixOutcome::Fixed(name) => fixed.push(name), + FixOutcome::Clean(_) => {} + FixOutcome::Fixed(result) => fixed.push(result.name), FixOutcome::Partial(result) => partial.push(result.name), FixOutcome::Review(result) => review.push(result.name), } @@ -661,9 +707,9 @@ fn finish_check_results(results: Vec, active: &[®istry::Check], fn classify_single_pass_fix(result: CheckResult) -> FixOutcome { if result.ok { if result.changed { - FixOutcome::Fixed(result.name) + FixOutcome::Fixed(result) } else { - FixOutcome::Clean + FixOutcome::Clean(result) } } else if result.changed { FixOutcome::Partial(result) @@ -702,7 +748,7 @@ async fn run_checks( baseline_file_list: Option<&files::FileList>, baseline_names: &HashSet, opts: RunOptions, - ctx: RunContext<'_>, + ctx: ExecutionContext<'_>, ) -> Result> { let (baseline, normal): (Vec<_>, Vec<_>) = checks .iter() @@ -714,12 +760,14 @@ async fn run_checks( results.extend( runner::run( &normal, - ctx.active_checks, - file_list, + RunnerRunContext { + active_checks: ctx.active_checks, + file_list, + project_root: ctx.project_root, + cfg: ctx.cfg, + config_dir: ctx.config_dir, + }, opts, - ctx.project_root, - ctx.cfg, - ctx.config_dir, ) .await?, ); @@ -729,12 +777,14 @@ async fn run_checks( results.extend( runner::run( &baseline, - ctx.active_checks, - files, + RunnerRunContext { + active_checks: ctx.active_checks, + file_list: files, + project_root: ctx.project_root, + cfg: ctx.cfg, + config_dir: ctx.config_dir, + }, opts, - ctx.project_root, - ctx.cfg, - ctx.config_dir, ) .await?, ); @@ -956,8 +1006,8 @@ pub fn linter_json(check: ®istry::Check) -> serde_json::Value { "binary": if check.uses_binary() { check.bin_name } else { "(built-in)" }, "patterns": patterns, "fix": check.has_fix(), - "run_policy": run_policy_label(check.run_policy), - "slow": check.run_policy == RunPolicy::Slow, + "run_policy": run_policy_label(check), + "slow": check.category == registry::Category::Slow, "scope": scope, "config_file": config_file, }) @@ -967,11 +1017,13 @@ fn canonical_config_path(config: &LinterConfig) -> String { config.canonical_location() } -fn run_policy_label(run_policy: RunPolicy) -> &'static str { - match run_policy { - RunPolicy::Fast => "fast", - RunPolicy::Slow => "slow", - RunPolicy::Adaptive => "adaptive", +fn run_policy_label(check: ®istry::Check) -> &'static str { + if check.adaptive_relevance.is_some() { + "adaptive" + } else if check.category == registry::Category::Slow { + "slow" + } else { + "fast" } } @@ -1053,7 +1105,7 @@ where for check in registry { let status = linter_status(check, mise_tools, cfg, &binary_on_path); - let speed = run_policy_label(check.run_policy); + let speed = run_policy_label(check); let fix = if check.has_fix() { "yes" } else { "no" }; let patterns_str = check.patterns.join(" "); let binary = display_binary(check); @@ -1129,10 +1181,27 @@ fn display_binary(check: ®istry::Check) -> &'static str { #[cfg(test)] mod tests { - use super::{display_binary, linter_status, render_linters_table, unsupported_config}; + use super::{ + RunArgs, display_binary, linter_status, render_linters_table, unsupported_config, + use_filtered_run_policy, + }; use crate::{config, registry}; use std::path::Path; + fn run_args() -> RunArgs { + RunArgs { + fix: false, + allow_fixed: false, + full: false, + verbose: false, + short: false, + new_from_rev: None, + to_ref: None, + time: false, + linters: Vec::new(), + } + } + fn mise_tools_from(content: &str) -> std::collections::HashMap { let dir = tempfile::tempdir().expect("tempdir"); std::fs::write(dir.path().join("mise.toml"), content).expect("write mise.toml"); @@ -1251,6 +1320,31 @@ license-header (built-in) not configured fast no Check s assert_eq!(display_binary(&license_header), "(built-in)"); } + #[test] + fn filtered_run_policy_is_default_outside_ci() { + assert!(use_filtered_run_policy(&run_args(), false, false)); + } + + #[test] + fn filtered_run_policy_is_disabled_by_default_in_ci() { + assert!(!use_filtered_run_policy(&run_args(), false, true)); + } + + #[test] + fn filtered_run_policy_is_disabled_for_full_runs() { + let mut args = run_args(); + args.full = true; + + assert!(!use_filtered_run_policy(&args, false, false)); + assert!(!use_filtered_run_policy(&args, false, true)); + } + + #[test] + fn filtered_run_policy_is_disabled_for_explicit_linter_selection() { + assert!(!use_filtered_run_policy(&run_args(), true, false)); + assert!(!use_filtered_run_policy(&run_args(), true, true)); + } + #[test] fn typos_supported_root_config_is_not_flagged_when_config_dir_is_project_root() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/registry/checks.rs b/src/registry/checks.rs index 862d7768..068335ff 100644 --- a/src/registry/checks.rs +++ b/src/registry/checks.rs @@ -440,7 +440,7 @@ fn check_lychee() -> Check { fn check_renovate_deps() -> Check { Check::native(&renovate_deps::CHECK_TYPE) - .adaptive() + .slow() .adaptive_relevance(renovate_deps::adaptive_relevance) .mise_tool("npm:renovate") .patterns(RENOVATE_CONFIG_PATTERNS) diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 55572846..f2201491 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -16,7 +16,7 @@ pub use types::{ ConfigMatch, EditorconfigDirectiveStyle, EditorconfigLineLengthPolicy, FixBehavior, InitHookContext, LinterConfig, LinterOutput, MissingComponentHint, NativeCheck, NativeCheckDef, NativePrepareContext, NativeRunContext, NativeRunFuture, NonverboseFailureOutputHook, - PreparedNativeCheck, RunPolicy, Scope, SetupOutcome, StatusContext, WorkflowSetup, + PreparedNativeCheck, Scope, SetupOutcome, StatusContext, WorkflowSetup, }; /// Returns the explicit set of flint-managed tool keys that belong under the diff --git a/src/registry/tests.rs b/src/registry/tests.rs index ad76ef41..9e696310 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -368,22 +368,6 @@ fn editorconfig_checker_json_is_optional_not_generated_baseline() { ); } -#[test] -fn adaptive_checks_declare_relevance_hooks() { - let missing: Vec<_> = builtin() - .into_iter() - .filter(|check| check.run_policy == RunPolicy::Adaptive) - .filter(|check| check.adaptive_relevance.is_none()) - .map(|check| check.name) - .collect(); - - assert!( - missing.is_empty(), - "adaptive checks missing relevance hooks: {}", - missing.join(", ") - ); -} - #[test] fn default_renovate_preset_covers_all_linter_tools_weekly() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); @@ -1013,17 +997,14 @@ fn detail_rows(check: &Check) -> Vec<(&'static str, String)> { } } - match check.run_policy { - crate::registry::RunPolicy::Fast => {} - crate::registry::RunPolicy::Slow => { - rows.push(("Run policy", "slow — skipped by `--fast-only`".to_string())); - } - crate::registry::RunPolicy::Adaptive => { - rows.push(( - "Run policy", - "adaptive — runs in `--fast-only` only when relevant".to_string(), - )); - } + if check.adaptive_relevance.is_some() { + let label = if check.name == "renovate-deps" { + "adaptive — see [when does this run?](linters/renovate-deps.md#when-does-this-run)" + .to_string() + } else { + "adaptive — runs on local default runs only when changed files are relevant".to_string() + }; + rows.push(("Run policy", label)); } rows diff --git a/src/registry/types.rs b/src/registry/types.rs index 04830b9a..b72f8999 100644 --- a/src/registry/types.rs +++ b/src/registry/types.rs @@ -40,18 +40,6 @@ pub enum Category { Slow, } -/// How a check participates in `--fast-only`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum RunPolicy { - /// Always runs, including in `--fast-only`. - #[default] - Fast, - /// Skipped in `--fast-only` unless explicitly named. - Slow, - /// Runs in `--fast-only` only when the changed files are relevant to the check. - Adaptive, -} - #[derive(Debug, Clone, Copy)] pub struct NativeCheckRef { native: &'static dyn NativeCheck, @@ -262,6 +250,7 @@ pub struct NativePrepareContext<'a> { pub struct NativeRunContext { pub fix: bool, + pub verbose: bool, pub project_root: PathBuf, } @@ -471,7 +460,6 @@ pub struct Check { /// dedicated formatter already owns. pub excludes_if_active: &'static [&'static str], pub category: Category, - pub run_policy: RunPolicy, /// When set, look for linter config in `config_dir` and inject an argument /// right after the binary name. pub linter_config: Option, @@ -495,7 +483,8 @@ pub struct Check { pub tool_key_migrations: Vec, /// Optional check-type behavior shared by related checks. pub check_type: Option<&'static dyn CheckType>, - /// Optional relevance hook for adaptive checks in `--fast-only` mode. + /// Optional relevance hook. When set, the check is skipped on filtered + /// (local-default) runs unless the hook reports the changed files relevant. pub adaptive_relevance: Option, /// Optional status override shown by `flint linters`. pub status_hook: Option, @@ -643,7 +632,6 @@ impl Check { editorconfig_line_length_policy: EditorconfigLineLengthPolicy::Default, activate_unconditionally: false, category: Category::Default, - run_policy: RunPolicy::Fast, toolchain: None, kind: CheckKind::Template { check_cmd, @@ -693,7 +681,6 @@ impl Check { editorconfig_line_length_policy: EditorconfigLineLengthPolicy::Default, activate_unconditionally: false, category: Category::Default, - run_policy: RunPolicy::Fast, toolchain: None, windows_java_jar: false, workflow_setup: None, @@ -767,17 +754,10 @@ impl Check { self } - /// Mark as comprehensive-only in `flint init`, and skipped by `--fast-only`. + /// Mark as comprehensive-only in `flint init`. Pair with + /// `.adaptive_relevance(...)` to skip on local default runs when irrelevant. pub fn slow(mut self) -> Self { self.category = Category::Slow; - self.run_policy = RunPolicy::Slow; - self - } - - /// Mark as comprehensive-only in `flint init`, and relevance-gated in `--fast-only`. - pub fn adaptive(mut self) -> Self { - self.category = Category::Slow; - self.run_policy = RunPolicy::Adaptive; self } diff --git a/src/runner.rs b/src/runner.rs index 5548d738..46818db8 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -21,6 +21,16 @@ pub struct RunOptions { pub time: bool, } +#[derive(Clone, Copy)] +pub struct RunContext<'a> { + pub active_checks: &'a [&'a Check], + pub file_list: &'a FileList, + pub project_root: &'a Path, + pub cfg: &'a Config, + pub config_dir: &'a Path, +} + +#[derive(Clone)] pub struct CheckResult { pub name: String, pub ok: bool, @@ -117,6 +127,7 @@ impl PreparedCheck { let out = native .run(crate::registry::NativeRunContext { fix, + verbose, project_root: project_root.to_path_buf(), }) .await; @@ -139,12 +150,8 @@ impl PreparedCheck { pub async fn run( checks: &[&Check], - active_checks: &[&Check], - file_list: &FileList, + ctx: RunContext<'_>, opts: RunOptions, - project_root: &Path, - cfg: &Config, - config_dir: &Path, ) -> Result> { let RunOptions { fix, @@ -157,12 +164,12 @@ pub async fn run( .filter_map(|&check| { prepare( check, - file_list, + ctx.file_list, fix, - project_root, - active_checks, - cfg, - config_dir, + ctx.project_root, + ctx.active_checks, + ctx.cfg, + ctx.config_dir, ) }) .collect(); @@ -170,7 +177,7 @@ pub async fn run( if fix { let mut results = vec![]; for task in prepared { - let r = task.execute(fix, verbose, project_root).await; + let r = task.execute(fix, verbose, ctx.project_root).await; if !short && (verbose || !r.ok) { eprintln!("[{}]{}", r.name, format_duration_suffix(time, r.duration)); flush_output(&r.stdout, &r.stderr); @@ -182,7 +189,7 @@ pub async fn run( let mut set: JoinSet = JoinSet::new(); for task in prepared { - let root = project_root.to_path_buf(); + let root = ctx.project_root.to_path_buf(); set.spawn(async move { task.execute(false, verbose, &root).await }); } @@ -208,7 +215,6 @@ pub async fn run( Ok(collected) } -#[allow(clippy::too_many_arguments)] fn prepare( check: &Check, file_list: &FileList, @@ -652,7 +658,7 @@ fn fingerprint_files(files: &[PathBuf]) -> u64 { hasher.finish() } -fn format_duration_suffix(time: bool, duration: Duration) -> String { +pub(crate) fn format_duration_suffix(time: bool, duration: Duration) -> String { if !time { return String::new(); } @@ -742,7 +748,7 @@ fn shell_words(cmd: String) -> Vec { mod tests { use super::*; use crate::files::FileList; - use crate::registry::{Category, Check, CheckKind, RunPolicy, Scope}; + use crate::registry::{Category, Check, CheckKind, Scope}; use std::path::PathBuf; #[test] @@ -847,7 +853,6 @@ mod tests { editorconfig_line_length_policy: crate::registry::EditorconfigLineLengthPolicy::Default, activate_unconditionally: false, category: Category::Default, - run_policy: RunPolicy::Fast, toolchain: None, windows_java_jar: false, workflow_setup: None, 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 deleted file mode 100644 index 9e26dfee..00000000 --- a/tests/cases/general/fast-only-explicit-override/files/.github/renovate.json5 +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/tests/cases/general/fast-only-explicit-override/files/package.json b/tests/cases/general/fast-only-explicit-override/files/package.json deleted file mode 100644 index 9e26dfee..00000000 --- a/tests/cases/general/fast-only-explicit-override/files/package.json +++ /dev/null @@ -1 +0,0 @@ -{} \ 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 deleted file mode 100644 index e26ee4f3..00000000 --- a/tests/cases/general/fast-only-explicit-override/test.toml +++ /dev/null @@ -1,17 +0,0 @@ -# --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 -stderr = "flint: warning: GITHUB_COM_TOKEN or GITHUB_TOKEN is not set; renovate-deps GitHub requests may be rate limited\n" - -[expected.files] -".renovate-ran" = """ -""" - -[fake_bins] -renovate = ''' -#!/bin/sh -touch .renovate-ran -printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' -''' diff --git a/tests/cases/general/hook-install/test.toml b/tests/cases/general/hook-install/test.toml index 00ba0295..f96ba356 100644 --- a/tests/cases/general/hook-install/test.toml +++ b/tests/cases/general/hook-install/test.toml @@ -7,5 +7,5 @@ stdout = "installed pre-commit hook (.git/hooks/pre-commit)\n" ".git/hooks/pre-commit" = ''' #!/bin/sh # Installed by flint — run `flint hook install` to reinstall -mise exec -- flint run --fix --fast-only +mise exec -- flint run --fix ''' diff --git a/tests/cases/general/time-flag-fix/files/good.sh b/tests/cases/general/time-flag-fix/files/good.sh new file mode 100644 index 00000000..e37f89b3 --- /dev/null +++ b/tests/cases/general/time-flag-fix/files/good.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo ok diff --git a/tests/cases/general/time-flag-fix/files/mise.toml b/tests/cases/general/time-flag-fix/files/mise.toml new file mode 100644 index 00000000..915bce24 --- /dev/null +++ b/tests/cases/general/time-flag-fix/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +shellcheck = "0.10.0" +shfmt = "3.8.0" diff --git a/tests/cases/general/time-flag-fix/test.toml b/tests/cases/general/time-flag-fix/test.toml new file mode 100644 index 00000000..8d0a3ef3 --- /dev/null +++ b/tests/cases/general/time-flag-fix/test.toml @@ -0,0 +1,20 @@ +[expected] +args = "run --full --fix --time shfmt shellcheck" +exit = 1 +stderr = ''' +[shellcheck] Xms +lint error +[shfmt] Xms +flint: review: shellcheck +''' + +[fake_bins] +shellcheck = ''' +#!/bin/sh +printf '%s\n' 'lint error' >&2 +exit 1 +''' +shfmt = ''' +#!/bin/sh +exit 0 +''' diff --git a/tests/cases/general/fast-only-explicit-override/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/files/.github/renovate-tracked-deps.json similarity index 100% rename from tests/cases/general/fast-only-explicit-override/files/.github/renovate-tracked-deps.json rename to tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/files/.github/renovate-tracked-deps.json diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate.json5 b/tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/files/.github/renovate.json5 similarity index 100% rename from tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate.json5 rename to tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/files/.github/renovate.json5 diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/files/README.md b/tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/files/README.md similarity index 100% rename from tests/cases/renovate-deps/fast-only-irrelevant/files/README.md rename to tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/files/README.md diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/files/mise.toml b/tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/files/mise.toml similarity index 100% rename from tests/cases/renovate-deps/fast-only-irrelevant/files/mise.toml rename to tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/files/mise.toml diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/files/package.json b/tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/files/package.json similarity index 100% rename from tests/cases/renovate-deps/fast-only-irrelevant/files/package.json rename to tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/files/package.json diff --git a/tests/cases/renovate-deps/fast-only-relevant/test.toml b/tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/test.toml similarity index 92% rename from tests/cases/renovate-deps/fast-only-relevant/test.toml rename to tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/test.toml index fdff2a81..6029f235 100644 --- a/tests/cases/renovate-deps/fast-only-relevant/test.toml +++ b/tests/cases/renovate-deps/ci-default-runs-even-when-irrelevant/test.toml @@ -1,5 +1,5 @@ [expected] -args = "run --fast-only" +args = "run" exit = 0 [expected.files] @@ -7,6 +7,7 @@ exit = 0 """ [env] +CI = "true" GITHUB_TOKEN = "token" [fake_bins] diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/changes/README.md b/tests/cases/renovate-deps/fast-only-irrelevant/changes/README.md deleted file mode 100644 index ef2fc0fc..00000000 --- a/tests/cases/renovate-deps/fast-only-irrelevant/changes/README.md +++ /dev/null @@ -1 +0,0 @@ -# Updated diff --git a/tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate.json5 b/tests/cases/renovate-deps/fix-up-to-date/files/.renovaterc.json similarity index 100% rename from tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate.json5 rename to tests/cases/renovate-deps/fix-up-to-date/files/.renovaterc.json diff --git a/tests/cases/general/fast-only-explicit-override/files/mise.toml b/tests/cases/renovate-deps/fix-up-to-date/files/mise.toml similarity index 100% rename from tests/cases/general/fast-only-explicit-override/files/mise.toml rename to tests/cases/renovate-deps/fix-up-to-date/files/mise.toml diff --git a/tests/cases/renovate-deps/fix-up-to-date/files/package.json b/tests/cases/renovate-deps/fix-up-to-date/files/package.json new file mode 100644 index 00000000..19f07531 --- /dev/null +++ b/tests/cases/renovate-deps/fix-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/fix-up-to-date/files/renovate-tracked-deps.json b/tests/cases/renovate-deps/fix-up-to-date/files/renovate-tracked-deps.json new file mode 100644 index 00000000..d87f4100 --- /dev/null +++ b/tests/cases/renovate-deps/fix-up-to-date/files/renovate-tracked-deps.json @@ -0,0 +1,16 @@ +{ + "meta": {}, + "files": { + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, + "package.json": { + "npm": [ + "express", + "lodash" + ] + } + } +} diff --git a/tests/cases/renovate-deps/fix-up-to-date/test.toml b/tests/cases/renovate-deps/fix-up-to-date/test.toml new file mode 100644 index 00000000..026d8dec --- /dev/null +++ b/tests/cases/renovate-deps/fix-up-to-date/test.toml @@ -0,0 +1,6 @@ +[expected] +args = "run --full --fix renovate-deps" +exit = 0 + +[env] +GITHUB_TOKEN = "token" diff --git a/tests/cases/renovate-deps/full-fast-extract-only/files/mise.toml b/tests/cases/renovate-deps/full-fast-extract-only/files/mise.toml new file mode 100644 index 00000000..1faa014b --- /dev/null +++ b/tests/cases/renovate-deps/full-fast-extract-only/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"npm:renovate" = "43.136.3" diff --git a/tests/cases/renovate-deps/full-fast-extract-only/files/renovate-tracked-deps.json b/tests/cases/renovate-deps/full-fast-extract-only/files/renovate-tracked-deps.json new file mode 100644 index 00000000..e3a0d00f --- /dev/null +++ b/tests/cases/renovate-deps/full-fast-extract-only/files/renovate-tracked-deps.json @@ -0,0 +1,15 @@ +{ + "meta": { + "actionlint": { + "packageName": "rhysd/actionlint", + "datasource": "github-releases" + } + }, + "files": { + "mise.toml": { + "mise": [ + "actionlint" + ] + } + } +} diff --git a/tests/cases/renovate-deps/full-fast-extract-only/files/renovate.json5 b/tests/cases/renovate-deps/full-fast-extract-only/files/renovate.json5 new file mode 100644 index 00000000..e8514ac6 --- /dev/null +++ b/tests/cases/renovate-deps/full-fast-extract-only/files/renovate.json5 @@ -0,0 +1,8 @@ +{ + packageRules: [ + { + groupName: "linters", + matchDepNames: ["actionlint"] + } + ] +} diff --git a/tests/cases/renovate-deps/full-fast-extract-only/test.toml b/tests/cases/renovate-deps/full-fast-extract-only/test.toml new file mode 100644 index 00000000..cb0caf14 --- /dev/null +++ b/tests/cases/renovate-deps/full-fast-extract-only/test.toml @@ -0,0 +1,33 @@ +[expected] +args = "run --full renovate-deps" +exit = 0 + +[env] +GITHUB_TOKEN = "token" + +[expected.files] +".reno-mode" = "extract\n" +".reno-modes" = "extract\n" +".reno-count" = "1\n" + +[fake_bins] +renovate = ''' +#!/bin/sh +set -eu + +mode="" +for arg in "$@"; do + case "$arg" in + --dry-run=*) mode="${arg#--dry-run=}" ;; + esac +done +printf '%s\n' "$mode" > .reno-mode +printf '%s\n' "$mode" >> .reno-modes +count=0 +if [ -f .reno-count ]; then + count=$(cat .reno-count) +fi +count=$((count + 1)) +printf '%s\n' "$count" > .reno-count +printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"mise":[{"packageFile":"mise.toml","deps":[{"depName":"actionlint"}]}]}}' +''' diff --git a/tests/cases/renovate-deps/full-slow-lookup-when-rule-needs-meta/files/mise.toml b/tests/cases/renovate-deps/full-slow-lookup-when-rule-needs-meta/files/mise.toml new file mode 100644 index 00000000..1faa014b --- /dev/null +++ b/tests/cases/renovate-deps/full-slow-lookup-when-rule-needs-meta/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"npm:renovate" = "43.136.3" diff --git a/tests/cases/renovate-deps/full-slow-lookup-when-rule-needs-meta/files/renovate-tracked-deps.json b/tests/cases/renovate-deps/full-slow-lookup-when-rule-needs-meta/files/renovate-tracked-deps.json new file mode 100644 index 00000000..97889c24 --- /dev/null +++ b/tests/cases/renovate-deps/full-slow-lookup-when-rule-needs-meta/files/renovate-tracked-deps.json @@ -0,0 +1,10 @@ +{ + "meta": {}, + "files": { + "mise.toml": { + "mise": [ + "actionlint" + ] + } + } +} diff --git a/tests/cases/renovate-deps/full-slow-lookup-when-rule-needs-meta/files/renovate.json5 b/tests/cases/renovate-deps/full-slow-lookup-when-rule-needs-meta/files/renovate.json5 new file mode 100644 index 00000000..e8514ac6 --- /dev/null +++ b/tests/cases/renovate-deps/full-slow-lookup-when-rule-needs-meta/files/renovate.json5 @@ -0,0 +1,8 @@ +{ + packageRules: [ + { + groupName: "linters", + matchDepNames: ["actionlint"] + } + ] +} diff --git a/tests/cases/renovate-deps/full-slow-lookup-when-rule-needs-meta/test.toml b/tests/cases/renovate-deps/full-slow-lookup-when-rule-needs-meta/test.toml new file mode 100644 index 00000000..41ee3cde --- /dev/null +++ b/tests/cases/renovate-deps/full-slow-lookup-when-rule-needs-meta/test.toml @@ -0,0 +1,48 @@ +[expected] +args = "run --full --fix renovate-deps" +exit = 1 +stderr = ''' +flint: fixed: renovate-deps — commit before pushing +''' + +[env] +GITHUB_TOKEN = "token" + +[expected.files] +".reno-mode" = "lookup\n" +"renovate-tracked-deps.json" = """ +{ + "meta": { + "actionlint": { + "packageName": "rhysd/actionlint", + "datasource": "github-releases" + } + }, + "files": { + "mise.toml": { + "mise": [ + "actionlint" + ] + } + } +} +""" + +[fake_bins] +renovate = ''' +#!/bin/sh +set -eu + +mode="" +for arg in "$@"; do + case "$arg" in + --dry-run=*) mode="${arg#--dry-run=}" ;; + esac +done +printf '%s\n' "$mode" > .reno-mode +if [ "$mode" = "lookup" ]; then + printf '%s\n' '{"msg":"packageFiles with updates","config":{"mise":[{"packageFile":"mise.toml","deps":[{"depName":"actionlint","packageName":"rhysd/actionlint","datasource":"github-releases"}]}]}}' +else + printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"mise":[{"packageFile":"mise.toml","deps":[{"depName":"actionlint"}]}]}}' +fi +''' diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/irrelevant/files/.github/renovate-tracked-deps.json similarity index 100% rename from tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate-tracked-deps.json rename to tests/cases/renovate-deps/irrelevant/files/.github/renovate-tracked-deps.json diff --git a/tests/cases/renovate-deps/fast-only-relevant/files/package.json b/tests/cases/renovate-deps/irrelevant/files/.github/renovate.json5 similarity index 100% rename from tests/cases/renovate-deps/fast-only-relevant/files/package.json rename to tests/cases/renovate-deps/irrelevant/files/.github/renovate.json5 diff --git a/tests/cases/renovate-deps/irrelevant/files/README.md b/tests/cases/renovate-deps/irrelevant/files/README.md new file mode 100644 index 00000000..8ae05696 --- /dev/null +++ b/tests/cases/renovate-deps/irrelevant/files/README.md @@ -0,0 +1 @@ +# Test diff --git a/tests/cases/renovate-deps/fast-only-relevant/files/mise.toml b/tests/cases/renovate-deps/irrelevant/files/mise.toml similarity index 100% rename from tests/cases/renovate-deps/fast-only-relevant/files/mise.toml rename to tests/cases/renovate-deps/irrelevant/files/mise.toml diff --git a/tests/cases/renovate-deps/irrelevant/files/package.json b/tests/cases/renovate-deps/irrelevant/files/package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/cases/renovate-deps/irrelevant/files/package.json @@ -0,0 +1 @@ +{} diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/test.toml b/tests/cases/renovate-deps/irrelevant/test.toml similarity index 51% rename from tests/cases/renovate-deps/fast-only-irrelevant/test.toml rename to tests/cases/renovate-deps/irrelevant/test.toml index e21b1ec9..d8b6fb2d 100644 --- a/tests/cases/renovate-deps/fast-only-irrelevant/test.toml +++ b/tests/cases/renovate-deps/irrelevant/test.toml @@ -1,5 +1,5 @@ [expected] -args = "run --fast-only" +args = "run" exit = 0 [env] @@ -8,6 +8,6 @@ GITHUB_TOKEN = "token" [fake_bins] renovate = ''' #!/bin/sh -echo "renovate should not run for unrelated fast-only changes" >&2 +echo "renovate should not run for unrelated changes" >&2 exit 1 ''' diff --git a/tests/cases/renovate-deps/fast-only-relevant/changes/package.json b/tests/cases/renovate-deps/local-default-relevant/changes/package.json similarity index 100% rename from tests/cases/renovate-deps/fast-only-relevant/changes/package.json rename to tests/cases/renovate-deps/local-default-relevant/changes/package.json diff --git a/tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/local-default-relevant/files/.github/renovate-tracked-deps.json similarity index 100% rename from tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate-tracked-deps.json rename to tests/cases/renovate-deps/local-default-relevant/files/.github/renovate-tracked-deps.json diff --git a/tests/cases/renovate-deps/local-default-relevant/files/.github/renovate.json5 b/tests/cases/renovate-deps/local-default-relevant/files/.github/renovate.json5 new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/cases/renovate-deps/local-default-relevant/files/.github/renovate.json5 @@ -0,0 +1 @@ +{} diff --git a/tests/cases/renovate-deps/local-default-relevant/files/mise.toml b/tests/cases/renovate-deps/local-default-relevant/files/mise.toml new file mode 100644 index 00000000..e9ee820b --- /dev/null +++ b/tests/cases/renovate-deps/local-default-relevant/files/mise.toml @@ -0,0 +1,5 @@ +[tools] +node = "22.0.0" + +# Linters +"npm:renovate" = "43.136.3" diff --git a/tests/cases/renovate-deps/local-default-relevant/files/package.json b/tests/cases/renovate-deps/local-default-relevant/files/package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/cases/renovate-deps/local-default-relevant/files/package.json @@ -0,0 +1 @@ +{} diff --git a/tests/cases/renovate-deps/local-default-relevant/test.toml b/tests/cases/renovate-deps/local-default-relevant/test.toml new file mode 100644 index 00000000..001c5d24 --- /dev/null +++ b/tests/cases/renovate-deps/local-default-relevant/test.toml @@ -0,0 +1,17 @@ +[expected] +args = "run" +exit = 0 + +[expected.files] +".renovate-ran" = """ +""" + +[env] +GITHUB_TOKEN = "token" + +[fake_bins] +renovate = ''' +#!/bin/sh +touch .renovate-ran +printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}}' +''' diff --git a/tests/cases/renovate-deps/metadata-fresh-fix-stays-fast/files/mise.toml b/tests/cases/renovate-deps/metadata-fresh-fix-stays-fast/files/mise.toml new file mode 100644 index 00000000..1faa014b --- /dev/null +++ b/tests/cases/renovate-deps/metadata-fresh-fix-stays-fast/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"npm:renovate" = "43.136.3" diff --git a/tests/cases/renovate-deps/metadata-fresh-fix-stays-fast/files/renovate-tracked-deps.json b/tests/cases/renovate-deps/metadata-fresh-fix-stays-fast/files/renovate-tracked-deps.json new file mode 100644 index 00000000..30055f0e --- /dev/null +++ b/tests/cases/renovate-deps/metadata-fresh-fix-stays-fast/files/renovate-tracked-deps.json @@ -0,0 +1,20 @@ +{ + "meta": { + "mise": { + "packageName": "jdx/mise", + "datasource": "github-release-attachments" + } + }, + "files": { + "mise.toml": { + "mise": [ + "mise" + ] + }, + "src/init/scaffold.rs": { + "regex": [ + "Swatinem/rust-cache" + ] + } + } +} diff --git a/tests/cases/renovate-deps/metadata-fresh-fix-stays-fast/files/renovate.json5 b/tests/cases/renovate-deps/metadata-fresh-fix-stays-fast/files/renovate.json5 new file mode 100644 index 00000000..f5a32a29 --- /dev/null +++ b/tests/cases/renovate-deps/metadata-fresh-fix-stays-fast/files/renovate.json5 @@ -0,0 +1,8 @@ +{ + packageRules: [ + { + groupName: "mise", + matchPackageNames: ["jdx/mise"] + } + ] +} diff --git a/tests/cases/renovate-deps/metadata-fresh-fix-stays-fast/test.toml b/tests/cases/renovate-deps/metadata-fresh-fix-stays-fast/test.toml new file mode 100644 index 00000000..b6df1a95 --- /dev/null +++ b/tests/cases/renovate-deps/metadata-fresh-fix-stays-fast/test.toml @@ -0,0 +1,33 @@ +[expected] +args = "run --full --fix renovate-deps" +exit = 0 + +[env] +GITHUB_TOKEN = "token" + +[expected.files] +".reno-mode" = "extract\n" +".reno-modes" = "extract\n" +".reno-count" = "1\n" + +[fake_bins] +renovate = ''' +#!/bin/sh +set -eu + +mode="" +for arg in "$@"; do + case "$arg" in + --dry-run=*) mode="${arg#--dry-run=}" ;; + esac +done +printf '%s\n' "$mode" > .reno-mode +printf '%s\n' "$mode" >> .reno-modes +count=0 +if [ -f .reno-count ]; then + count=$(cat .reno-count) +fi +count=$((count + 1)) +printf '%s\n' "$count" > .reno-count +printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"regex":[{"packageFile":"src/init/scaffold.rs","deps":[{"depName":"Swatinem/rust-cache"}]}],"mise":[{"packageFile":"mise.toml","deps":[{"depName":"mise"}]}]}}' +''' diff --git a/tests/cases/renovate-deps/metadata-stale-fix-refreshes/files/mise.toml b/tests/cases/renovate-deps/metadata-stale-fix-refreshes/files/mise.toml new file mode 100644 index 00000000..1faa014b --- /dev/null +++ b/tests/cases/renovate-deps/metadata-stale-fix-refreshes/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"npm:renovate" = "43.136.3" diff --git a/tests/cases/renovate-deps/metadata-stale-fix-refreshes/files/renovate-tracked-deps.json b/tests/cases/renovate-deps/metadata-stale-fix-refreshes/files/renovate-tracked-deps.json new file mode 100644 index 00000000..f3abd525 --- /dev/null +++ b/tests/cases/renovate-deps/metadata-stale-fix-refreshes/files/renovate-tracked-deps.json @@ -0,0 +1,15 @@ +{ + "meta": {}, + "files": { + "mise.toml": { + "mise": [ + "mise" + ] + }, + "src/init/scaffold.rs": { + "regex": [ + "Swatinem/rust-cache" + ] + } + } +} diff --git a/tests/cases/renovate-deps/metadata-stale-fix-refreshes/files/renovate.json5 b/tests/cases/renovate-deps/metadata-stale-fix-refreshes/files/renovate.json5 new file mode 100644 index 00000000..f5a32a29 --- /dev/null +++ b/tests/cases/renovate-deps/metadata-stale-fix-refreshes/files/renovate.json5 @@ -0,0 +1,8 @@ +{ + packageRules: [ + { + groupName: "mise", + matchPackageNames: ["jdx/mise"] + } + ] +} diff --git a/tests/cases/renovate-deps/metadata-stale-fix-refreshes/test.toml b/tests/cases/renovate-deps/metadata-stale-fix-refreshes/test.toml new file mode 100644 index 00000000..e80da1d2 --- /dev/null +++ b/tests/cases/renovate-deps/metadata-stale-fix-refreshes/test.toml @@ -0,0 +1,65 @@ +[expected] +args = "run --full --fix renovate-deps" +exit = 1 +stderr = ''' +flint: fixed: renovate-deps — commit before pushing +''' + +[env] +GITHUB_TOKEN = "token" + +[expected.files] +".reno-mode" = "lookup\n" +".reno-modes" = """ +extract +lookup +""" +".reno-count" = "2\n" +"renovate-tracked-deps.json" = """ +{ + "meta": { + "mise": { + "packageName": "jdx/mise", + "datasource": "github-release-attachments" + } + }, + "files": { + "mise.toml": { + "mise": [ + "mise" + ] + }, + "src/init/scaffold.rs": { + "regex": [ + "Swatinem/rust-cache" + ] + } + } +} +""" + +[fake_bins] +renovate = ''' +#!/bin/sh +set -eu + +mode="" +for arg in "$@"; do + case "$arg" in + --dry-run=*) mode="${arg#--dry-run=}" ;; + esac +done +printf '%s\n' "$mode" > .reno-mode +printf '%s\n' "$mode" >> .reno-modes +count=0 +if [ -f .reno-count ]; then + count=$(cat .reno-count) +fi +count=$((count + 1)) +printf '%s\n' "$count" > .reno-count +if [ "$mode" = "lookup" ]; then + printf '%s\n' '{"msg":"packageFiles with updates","config":{"regex":[{"packageFile":"src/init/scaffold.rs","deps":[{"depName":"Swatinem/rust-cache","packageName":"Swatinem/rust-cache","datasource":"github-tags"}]}],"mise":[{"packageFile":"mise.toml","deps":[{"depName":"mise","packageName":"jdx/mise","datasource":"github-release-attachments"}]}]}}' +else + printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"regex":[{"packageFile":"src/init/scaffold.rs","deps":[{"depName":"Swatinem/rust-cache"}]}],"mise":[{"packageFile":"mise.toml","deps":[{"depName":"mise"}]}]}}' +fi +''' diff --git a/tests/cases/renovate-deps/metadata-stale-lint-fails/files/mise.toml b/tests/cases/renovate-deps/metadata-stale-lint-fails/files/mise.toml new file mode 100644 index 00000000..1faa014b --- /dev/null +++ b/tests/cases/renovate-deps/metadata-stale-lint-fails/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"npm:renovate" = "43.136.3" diff --git a/tests/cases/renovate-deps/metadata-stale-lint-fails/files/renovate-tracked-deps.json b/tests/cases/renovate-deps/metadata-stale-lint-fails/files/renovate-tracked-deps.json new file mode 100644 index 00000000..f3abd525 --- /dev/null +++ b/tests/cases/renovate-deps/metadata-stale-lint-fails/files/renovate-tracked-deps.json @@ -0,0 +1,15 @@ +{ + "meta": {}, + "files": { + "mise.toml": { + "mise": [ + "mise" + ] + }, + "src/init/scaffold.rs": { + "regex": [ + "Swatinem/rust-cache" + ] + } + } +} diff --git a/tests/cases/renovate-deps/metadata-stale-lint-fails/files/renovate.json5 b/tests/cases/renovate-deps/metadata-stale-lint-fails/files/renovate.json5 new file mode 100644 index 00000000..f5a32a29 --- /dev/null +++ b/tests/cases/renovate-deps/metadata-stale-lint-fails/files/renovate.json5 @@ -0,0 +1,8 @@ +{ + packageRules: [ + { + groupName: "mise", + matchPackageNames: ["jdx/mise"] + } + ] +} diff --git a/tests/cases/renovate-deps/metadata-stale-lint-fails/test.toml b/tests/cases/renovate-deps/metadata-stale-lint-fails/test.toml new file mode 100644 index 00000000..492a50d6 --- /dev/null +++ b/tests/cases/renovate-deps/metadata-stale-lint-fails/test.toml @@ -0,0 +1,28 @@ +[expected] +args = "run --full renovate-deps" +exit = 1 +stderr_contains = [ + "flint: renovate-deps: dependency metadata is out of date", + "Run `flint run --fix renovate-deps` to refresh metadata.", +] + +[env] +GITHUB_TOKEN = "token" + +[expected.files] +".reno-mode" = "extract\n" + +[fake_bins] +renovate = ''' +#!/bin/sh +set -eu + +mode="" +for arg in "$@"; do + case "$arg" in + --dry-run=*) mode="${arg#--dry-run=}" ;; + esac +done +printf '%s\n' "$mode" > .reno-mode +printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"regex":[{"packageFile":"src/init/scaffold.rs","deps":[{"depName":"Swatinem/rust-cache"}]}],"mise":[{"packageFile":"mise.toml","deps":[{"depName":"mise"}]}]}}' +''' diff --git a/tests/e2e.rs b/tests/e2e.rs index 1c36d531..bcc4c82b 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -168,7 +168,7 @@ fn cases() { // marks it executable with Unix permissions. #[cfg(unix)] #[test] -fn renovate_deps_fast_only_runs_for_deleted_tracked_file() { +fn renovate_deps_local_default_runs_for_deleted_tracked_file() { let repo = git_repo(); std::fs::create_dir_all(repo.path().join(".github")).unwrap(); @@ -250,11 +250,7 @@ printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"mise":[{"package fake_bin_dir.path().display(), std::env::var("PATH").unwrap_or_default() ); - let out = flint_with_env( - &["run", "--fast-only"], - repo.path(), - &[("PATH", &fake_path)], - ); + let out = flint_with_env(&["run"], repo.path(), &[("PATH", &fake_path)]); assert!( repo.path().join(".renovate-ran").exists(),