diff --git a/.github/renovate-tracked-deps.json b/.github/renovate-tracked-deps.json index 33bfc8bb..984f3e3e 100644 --- a/.github/renovate-tracked-deps.json +++ b/.github/renovate-tracked-deps.json @@ -1,63 +1,138 @@ { - ".github/workflows/lint.yml": { - "regex": [ - "mise" - ] + "meta": { + "actionlint": { + "packageName": "rhysd/actionlint", + "datasource": "github-releases" + }, + "aqua:owenlamont/ryl": { + "packageName": "owenlamont/ryl", + "datasource": "github-tags" + }, + "biome": { + "packageName": "biome" + }, + "editorconfig-checker": { + "packageName": "editorconfig-checker/editorconfig-checker", + "datasource": "github-releases" + }, + "github:google/google-java-format": { + "packageName": "google/google-java-format", + "datasource": "github-releases" + }, + "github:jonwiggins/xmloxide": { + "packageName": "jonwiggins/xmloxide", + "datasource": "github-releases" + }, + "github:koalaman/shellcheck": { + "packageName": "koalaman/shellcheck", + "datasource": "github-releases" + }, + "golangci-lint": { + "packageName": "golangci/golangci-lint", + "datasource": "github-tags" + }, + "hadolint": { + "packageName": "hadolint/hadolint", + "datasource": "github-tags" + }, + "ktlint": { + "packageName": "pinterest/ktlint", + "datasource": "github-releases" + }, + "lychee": { + "packageName": "lycheeverse/lychee", + "datasource": "github-releases" + }, + "mise": { + "packageName": "jdx/mise", + "datasource": "github-release-attachments" + }, + "npm:renovate": { + "packageName": "renovate", + "datasource": "npm" + }, + "pipx:codespell": { + "packageName": "codespell", + "datasource": "pypi" + }, + "ruff": { + "packageName": "astral-sh/ruff", + "datasource": "github-releases" + }, + "rumdl": { + "packageName": "rvben/rumdl", + "datasource": "github-releases" + }, + "shfmt": { + "packageName": "mvdan/sh", + "datasource": "github-releases" + }, + "taplo": { + "packageName": "tamasfe/taplo", + "datasource": "github-releases" + } }, - ".github/workflows/release-assets.yml": { - "regex": [ - "mise" - ] - }, - ".github/workflows/release-plz.yml": { - "regex": [ - "mise" - ] - }, - ".github/workflows/test.yml": { - "regex": [ - "mise" - ] - }, - "README.md": { - "regex": [ - "koalaman/shellcheck", - "mvdan/sh", - "rhysd/actionlint" - ] - }, - "mise.toml": { - "mise": [ - "actionlint", - "aqua:owenlamont/ryl", - "biome", - "dotnet", - "editorconfig-checker", - "github:google/google-java-format", - "github:jonwiggins/xmloxide", - "github:koalaman/shellcheck", - "go", - "golangci-lint", - "hadolint", - "ktlint", - "lychee", - "node", - "npm:renovate", - "pipx:codespell", - "release-plz", - "ruff", - "rumdl", - "rust", - "shfmt", - "taplo" - ] - }, - "src/init/scaffold.rs": { - "regex": [ - "Swatinem/rust-cache", - "actions/checkout", - "jdx/mise-action", - "mise" - ] + "files": { + ".github/workflows/lint.yml": { + "regex": [ + "mise" + ] + }, + ".github/workflows/release-assets.yml": { + "regex": [ + "mise" + ] + }, + ".github/workflows/release-plz.yml": { + "regex": [ + "mise" + ] + }, + ".github/workflows/test.yml": { + "regex": [ + "mise" + ] + }, + "README.md": { + "regex": [ + "actionlint", + "github:koalaman/shellcheck", + "shfmt" + ] + }, + "mise.toml": { + "mise": [ + "actionlint", + "aqua:owenlamont/ryl", + "biome", + "dotnet", + "editorconfig-checker", + "github:google/google-java-format", + "github:jonwiggins/xmloxide", + "github:koalaman/shellcheck", + "go", + "golangci-lint", + "hadolint", + "ktlint", + "lychee", + "node", + "npm:renovate", + "pipx:codespell", + "release-plz", + "ruff", + "rumdl", + "rust", + "shfmt", + "taplo" + ] + }, + "src/init/scaffold.rs": { + "regex": [ + "Swatinem/rust-cache", + "actions/checkout", + "jdx/mise-action", + "mise" + ] + } } } diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 74fedba3..5fa19c28 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -31,7 +31,8 @@ "# Add whichever linters apply to your repo:\\n[\\s\\S]*?\"github:koalaman/shellcheck\"\\s*=\\s*\"(?[^\"]+)\"[\\s\\S]*?\\n```", ], datasourceTemplate: "github-releases", - depNameTemplate: "koalaman/shellcheck", + depNameTemplate: "github:koalaman/shellcheck", + packageNameTemplate: "koalaman/shellcheck", }, { customType: "regex", @@ -41,7 +42,8 @@ "# Add whichever linters apply to your repo:\\n[\\s\\S]*?shfmt\\s*=\\s*\"(?[^\"]+)\"[\\s\\S]*?\\n```", ], datasourceTemplate: "github-releases", - depNameTemplate: "mvdan/sh", + depNameTemplate: "shfmt", + packageNameTemplate: "mvdan/sh", }, { customType: "regex", @@ -51,7 +53,8 @@ "# Add whichever linters apply to your repo:\\n[\\s\\S]*?actionlint\\s*=\\s*\"(?[^\"]+)\"[\\s\\S]*?\\n```", ], datasourceTemplate: "github-releases", - depNameTemplate: "rhysd/actionlint", + depNameTemplate: "actionlint", + packageNameTemplate: "rhysd/actionlint", }, { customType: "regex", @@ -92,7 +95,7 @@ ], packageRules: [ { - matchPackageNames: [ + matchDepNames: [ "actionlint", "aqua:owenlamont/ryl", "biome", diff --git a/default.json b/default.json index ea36e9dc..448b4523 100644 --- a/default.json +++ b/default.json @@ -36,7 +36,7 @@ ], "packageRules": [ { - "matchPackageNames": [ + "matchDepNames": [ "actionlint", "aqua:owenlamont/ryl", "biome", diff --git a/docs/linters.md b/docs/linters.md index 572d4bca..83ddc3be 100644 --- a/docs/linters.md +++ b/docs/linters.md @@ -227,8 +227,13 @@ check_all_local = true | Patterns | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | | Run policy | adaptive — runs in `--fast-only` only when relevant | -Verifies `.github/renovate-tracked-deps.json` is up to date by running -Renovate locally and comparing its output against the committed snapshot. +Verifies `renovate-tracked-deps.json` next to the active Renovate +config is up to date by running Renovate locally and comparing its +output against the committed snapshot. +It also checks that dependencies extracted from different files but +resolving to the same upstream package match the same Renovate +package rules. That catches config splits like `actionlint` vs +`rhysd/actionlint` before Renovate stops grouping them consistently. Requires `renovate` in `[tools]`. In CI, `renovate-deps` requires `GITHUB_COM_TOKEN` or `GITHUB_TOKEN` @@ -240,6 +245,10 @@ When `flint init` writes a new `flint.toml`, it includes this section if legacy `RENOVATE_TRACKED_DEPS_EXCLUDE` values into `exclude_managers`. With `--fix`, automatically regenerates and commits the snapshot. +For custom/regex managers, prefer canonical `depNameTemplate` values +for grouping and explicit `packageNameTemplate` values for datasource +lookups when those identities differ. +See [the renovate-deps guide](linters/renovate-deps.md) for examples. Configure via `flint.toml`: diff --git a/docs/linters/renovate-deps.md b/docs/linters/renovate-deps.md new file mode 100644 index 00000000..2fb7fe96 --- /dev/null +++ b/docs/linters/renovate-deps.md @@ -0,0 +1,116 @@ +# `renovate-deps` + +`renovate-deps` does two related checks: + +1. It verifies that `renovate-tracked-deps.json` next to the active Renovate + config matches what Renovate currently extracts from the repo. +2. It checks that extracted dependencies which resolve to the same upstream + package are covered consistently by Renovate package rules. + +The second check is there to catch configuration mistakes before they show up as +separate Renovate PRs or README drift. + +## What it catches + +Goal: `mise.toml` and `README.md` both refer to actionlint, so you want +Renovate to treat them as the same dependency and keep them in the same group. + +A setup can fail that goal by extracting different dependency names for the +same upstream package: + +```json5 +{ + packageRules: [ + { + groupName: "linters", + matchDepNames: ["actionlint"], + }, + ], + customManagers: [ + { + customType: "regex", + managerFilePatterns: ["/^README\\.md$/"], + datasourceTemplate: "github-releases", + depNameTemplate: "rhysd/actionlint", + }, + ], +} +``` + +Where it fails: + +- `mise.toml` extracts `actionlint` +- `README.md` extracts `rhysd/actionlint` +- the `linters` rule matches only `actionlint` + +Renovate can now stop grouping those occurrences consistently and update them +separately. + +`renovate-deps` reports that mismatch earlier, at config-check time. + +## Preferred pattern + +When a custom manager needs a different lookup identity than the grouping name, +set both values explicitly: + +```json5 +{ + customType: "regex", + datasourceTemplate: "github-releases", + depNameTemplate: "actionlint", + packageNameTemplate: "rhysd/actionlint", +} +``` + +Why: + +- `depNameTemplate` controls the extracted dependency name Flint uses for rule + matching comparisons +- `packageNameTemplate` keeps the datasource lookup pointed at the real upstream + package + +The same pattern applies to entries like: + +```json5 +depNameTemplate: "github:koalaman/shellcheck", +packageNameTemplate: "koalaman/shellcheck", +``` + +## Snapshot shape + +The committed `renovate-tracked-deps.json` snapshot lives next to the active +Renovate config: + +- `.github/renovate-tracked-deps.json` for `.github/renovate.json5` +- `renovate-tracked-deps.json` for root-level configs such as `.renovaterc.json` + +It stores only the metadata Flint needs for these checks: + +- `files`: extracted dependency names by file and manager +- `meta`: package metadata for deps relevant to rule-coverage validation + +This is intentionally narrower than full Renovate output so steady-state +`renovate-deps --fix` stays cheap. + +## Fixing failures + +If the snapshot is stale: + +```bash +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: + +```bash +FLINT_RENOVATE_DEPS_REFRESH_META=1 flint run --fix renovate-deps +``` + +If rule coverage is inconsistent: + +- normalize equivalent deps to one canonical `depNameTemplate` +- keep `packageNameTemplate` explicit when datasource lookup needs a different + identifier +- make sure the intended `packageRules` matcher covers that canonical dependency name diff --git a/src/config.rs b/src/config.rs index 5d2b4a41..5f5bcc1a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,6 +56,8 @@ 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/linters/renovate_deps.rs b/src/linters/renovate_deps.rs deleted file mode 100644 index ac91458a..00000000 --- a/src/linters/renovate_deps.rs +++ /dev/null @@ -1,1159 +0,0 @@ -use anyhow::Context; -use std::collections::{BTreeMap, BTreeSet, HashSet}; -use std::path::{Path, PathBuf}; -use std::process::Stdio; - -use crate::config::RenovateDepsConfig; -use crate::files::FileList; -use crate::linters::LinterOutput; -use crate::linters::env; -use crate::registry::{ - AdaptiveRelevanceContext, CheckTypeDef, InitHookContext, NativeCheckDef, NativePrepareContext, - NativeRunContext, NativeRunFuture, PreparedNativeCheck, -}; - -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_MSGS: &[&str] = &["Extracted dependencies", "packageFiles with updates"]; -const RENOVATE_GITHUB_TOKEN_DISPLAY: &str = "GITHUB_COM_TOKEN or GITHUB_TOKEN"; -const SKIP_REASONS: &[&str] = &["contains-variable", "invalid-value", "invalid-version"]; - -pub(crate) static CHECK_TYPE: CheckTypeDef = CheckTypeDef::native_with_init_hook( - "renovate-deps", - NativeCheckDef::with_bin("renovate", prepare).with_fix(), - init, -); - -#[derive(Debug)] -struct PreparedRenovateDeps { - name: String, - cfg: RenovateDepsConfig, - tracked_files: Vec, -} - -fn prepare(ctx: NativePrepareContext<'_>) -> Option> { - Some(Box::new(PreparedRenovateDeps { - name: ctx.name.to_string(), - cfg: ctx.cfg.checks.renovate_deps.clone(), - tracked_files: COMMITTED_PATHS - .iter() - .map(|path| ctx.project_root.join(path)) - .collect(), - })) -} - -impl PreparedNativeCheck for PreparedRenovateDeps { - fn name(&self) -> &str { - &self.name - } - - fn tracked_files(&self) -> &[PathBuf] { - &self.tracked_files - } - - fn run(self: Box, ctx: NativeRunContext) -> NativeRunFuture { - Box::pin(async move { - crate::linters::renovate_deps::run(&self.cfg, ctx.fix, &ctx.project_root).await - }) - } -} - -/// `{file_path: {manager: [dep_name, ...]}}` — all collections sorted. -type DepMap = BTreeMap>>; - -pub async fn run(cfg: &RenovateDepsConfig, fix: 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 { - Ok(out) => out, - Err(e) => LinterOutput::err(format!("flint: renovate-deps: {e}\n")), - } -} - -pub(crate) fn init(ctx: &dyn InitHookContext) -> anyhow::Result { - let toml_path = ctx.config_dir().join("flint.toml"); - let config_changed = if let Some(managers) = ctx.renovate_exclude_managers() - && !managers.is_empty() - { - configure_renovate_deps_config(&toml_path, Some(managers))? - } else if ctx.flint_toml_generated() { - configure_renovate_deps_config(&toml_path, None)? - } else { - false - }; - let preset_changed = patch_renovate_preset(ctx.project_root())?; - Ok(config_changed || preset_changed) -} - -fn validate_runtime_env() -> Result, String> { - validate_runtime_env_from(|name| std::env::var(name).ok()) -} - -fn validate_runtime_env_from(env: F) -> Result, String> -where - F: Fn(&str) -> Option, -{ - if env::renovate_github_token_available(&env) { - return Ok(None); - } - if env::is_ci_from(&env) { - return Err(format!( - "flint: renovate-deps: missing required CI environment variable: {token_display}\n Set {github_token}, or set {github_com_token} directly, so Renovate can authenticate GitHub requests in CI.\n", - token_display = RENOVATE_GITHUB_TOKEN_DISPLAY, - github_com_token = env::GITHUB_COM_TOKEN_ENV, - github_token = env::GITHUB_TOKEN_ENV, - )); - } - Ok(Some(env::token_warning( - "renovate-deps", - RENOVATE_GITHUB_TOKEN_DISPLAY, - ))) -} - -pub(crate) fn is_relevant(file_list: &FileList, project_root: &Path) -> bool { - if file_list.full { - return true; - } - - let changed = changed_rel_paths(file_list, project_root); - - if changed.is_empty() { - return false; - } - - if changed - .iter() - .any(|path| RENOVATE_CONFIG_PATTERNS.contains(&path.as_str())) - { - return true; - } - - let committed_path = COMMITTED_PATHS - .iter() - .map(|path| project_root.join(path)) - .find(|path| path.exists()); - - let Some(committed_path) = committed_path else { - return false; - }; - - let committed_rel = display_path(project_root, &committed_path); - if changed.contains(&committed_rel) { - return true; - } - - let committed: DepMap = match std::fs::read_to_string(&committed_path) - .ok() - .and_then(|contents| serde_json::from_str(&contents).ok()) - { - Some(committed) => committed, - None => return true, - }; - - committed.keys().any(|path| changed.contains(path)) -} - -fn changed_rel_paths(file_list: &FileList, project_root: &Path) -> HashSet { - if !file_list.changed_paths.is_empty() { - return file_list - .changed_paths - .iter() - .map(|path| { - let path = Path::new(path); - path.strip_prefix(project_root).unwrap_or(path) - }) - .map(normalize_path) - .collect(); - } - - file_list - .files - .iter() - .filter_map(|path| path.strip_prefix(project_root).ok()) - .map(normalize_path) - .collect() -} - -fn normalize_path(path: &Path) -> String { - path.components() - .map(|component| component.as_os_str().to_string_lossy()) - .collect::>() - .join("/") -} - -pub(crate) fn adaptive_relevance(ctx: &dyn AdaptiveRelevanceContext) -> bool { - is_relevant(ctx.file_list(), ctx.project_root()) -} - -/// Ensures `flint.toml` has the Renovate check config requested by init. -/// Returns `true` when the file was changed. -fn configure_renovate_deps_config( - toml_path: &Path, - exclude_managers: Option<&[String]>, -) -> anyhow::Result { - let content = std::fs::read_to_string(toml_path) - .with_context(|| format!("failed to read {}", toml_path.display()))?; - let mut doc: toml_edit::DocumentMut = content.parse().context("failed to parse flint.toml")?; - let Some(checks) = doc.get("checks").and_then(|item| item.as_table()) else { - return append_renovate_deps_config(toml_path, &content, exclude_managers); - }; - let Some(table_key) = ["renovate-deps", "renovate_deps"] - .into_iter() - .find(|key| checks.contains_key(key)) - else { - return append_renovate_deps_config(toml_path, &content, exclude_managers); - }; - - let Some(managers) = exclude_managers.filter(|managers| !managers.is_empty()) else { - return Ok(false); - }; - let renovate = doc - .get_mut("checks") - .and_then(|item| item.as_table_mut()) - .and_then(|checks| checks.get_mut(table_key)) - .and_then(|item| item.as_table_mut()) - .with_context(|| { - format!( - "[checks.{table_key}] is not a table in {}", - toml_path.display() - ) - })?; - if renovate.contains_key("exclude_managers") { - return Ok(false); - } - renovate.insert("exclude_managers", toml_edit::value(string_array(managers))); - std::fs::write(toml_path, doc.to_string()) - .with_context(|| format!("failed to write {}", toml_path.display()))?; - println!( - " patched {} — added checks.renovate-deps.exclude_managers", - toml_path.display() - ); - Ok(true) -} - -fn append_renovate_deps_config( - toml_path: &Path, - content: &str, - exclude_managers: Option<&[String]>, -) -> anyhow::Result { - let mut next = String::from(content); - if !next.ends_with('\n') { - next.push('\n'); - } - next.push_str("\n[checks.renovate-deps]\n"); - match exclude_managers { - Some(managers) if !managers.is_empty() => { - next.push_str(&format!("exclude_managers = {}\n", string_array(managers))); - } - _ => next.push_str("# exclude_managers = []\n"), - } - std::fs::write(toml_path, next) - .with_context(|| format!("failed to write {}", toml_path.display()))?; - println!( - " patched {} — added checks.renovate-deps", - toml_path.display() - ); - Ok(true) -} - -fn string_array(values: &[String]) -> toml_edit::Array { - let mut array = toml_edit::Array::default(); - for value in values { - array.push(value.as_str()); - } - array -} - -fn patch_renovate_preset(project_root: &Path) -> anyhow::Result { - let Some(path) = find_renovate_config(project_root) else { - return Ok(false); - }; - let changed = patch_renovate_extends(&path)?; - if changed { - let rel = path.strip_prefix(project_root).unwrap_or(&path); - println!(" patched {} — added {}", rel.display(), flint_preset()); - } - Ok(changed) -} - -fn find_renovate_config(project_root: &Path) -> Option { - RENOVATE_CONFIG_PATTERNS - .iter() - .map(|path| project_root.join(path)) - .find(|path| path.exists()) -} - -/// 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. -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. -fn patch_renovate_extends(path: &Path) -> anyhow::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) -> anyhow::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(|line| !line.trim().is_empty()) - .map(|line| " ".repeat(line.len() - line.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); - let separator = if after.trim() == "}" { "" } else { "," }; - Ok(format!( - "{}\n \"extends\": [\"{}\"]{}{}", - before, entry, separator, after - )) - } -} - -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![], - setup_outcome: None, - }); - } - 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![], - setup_outcome: None, - }); - } - - 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![], - setup_outcome: None, - }); - } - - 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(), - setup_outcome: None, - }) -} - -/// 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(env::GITHUB_COM_TOKEN_ENV) - .map(|v| !v.is_empty()) - .unwrap_or(false); - if !has_com_token - && let Ok(token) = std::env::var(env::GITHUB_TOKEN_ENV) - && !token.is_empty() - { - env.push((env::GITHUB_COM_TOKEN_ENV.into(), token)); - } - - let out = super::spawn_command( - &[ - "renovate".to_string(), - "--platform=local".to_string(), - "--require-config=ignored".to_string(), - "--dry-run=extract".to_string(), - ], - false, - ) - .current_dir(project_root) - .envs(env) - .stdin(Stdio::null()) - .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 { - normalize_path(path.strip_prefix(project_root).unwrap_or(path)) -} - -/// 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()) - .is_some_and(|msg| PACKAGE_FILES_MSGS.contains(&msg)) - { - let extracted_config = entry - .get("packageFiles") - .cloned() - .or_else(|| entry.get("config").cloned()); - if extracted_config.is_some() { - config_obj = extracted_config; - } - } - } - - let config = config_obj - .ok_or_else(|| anyhow::anyhow!("none of {:?} found in Renovate log", PACKAGE_FILES_MSGS))?; - - 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 log_current(config_json: &str) -> Vec { - format!(r#"{{"msg":"packageFiles with updates","config":{config_json}}}"#).into_bytes() - } - - #[allow(clippy::type_complexity)] - 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() - } - - fn validate_env(vars: &[(&str, &str)]) -> Result, String> { - let vars: std::collections::HashMap = vars - .iter() - .map(|(name, value)| (name.to_string(), value.to_string())) - .collect(); - validate_runtime_env_from(|name| vars.get(name).cloned()) - } - - fn write_tmp(content: &str) -> tempfile::NamedTempFile { - let file = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(file.path(), content).unwrap(); - file - } - - #[test] - fn configure_renovate_deps_appends_placeholder() { - let tmp = write_tmp("[settings]\n"); - let changed = configure_renovate_deps_config(tmp.path(), None).unwrap(); - assert!(changed); - let result = std::fs::read_to_string(tmp.path()).unwrap(); - assert!(result.contains("[checks.renovate-deps]")); - assert!(result.contains("# exclude_managers = []")); - } - - #[test] - fn configure_renovate_deps_appends_migrated_managers() { - let tmp = write_tmp("[settings]\n"); - let managers = vec!["github-actions".to_string(), "cargo".to_string()]; - let changed = configure_renovate_deps_config(tmp.path(), Some(&managers)).unwrap(); - assert!(changed); - let result = std::fs::read_to_string(tmp.path()).unwrap(); - assert!( - result.contains("exclude_managers = [\"github-actions\", \"cargo\"]"), - "managers written uncommented: {result}" - ); - assert!(!result.contains("# exclude_managers")); - } - - #[test] - fn configure_renovate_deps_keeps_existing_managers() { - let tmp = write_tmp("[checks.renovate-deps]\nexclude_managers = [\"npm\"]\n"); - let managers = vec!["github-actions".to_string(), "cargo".to_string()]; - let changed = configure_renovate_deps_config(tmp.path(), Some(&managers)).unwrap(); - assert!(!changed); - let result = std::fs::read_to_string(tmp.path()).unwrap(); - assert!(result.contains("exclude_managers = [\"npm\"]")); - assert!(!result.contains("github-actions")); - } - - #[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}" - ); - 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 = 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\",")); - 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_extends_when_absent_in_empty_object() { - let input = "{}\n"; - let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); - assert_eq!( - result, - "{\n \"extends\": [\"github>grafana/flint#v0.9.2\"]}\n" - ); - } - - #[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"]"#)); - } - - #[test] - fn ci_requires_github_token_or_github_com_token() { - let err = validate_env(&[("CI", "true")]).unwrap_err(); - - assert!(err.contains("GITHUB_COM_TOKEN"), "unexpected error:\n{err}"); - assert!(err.contains("GITHUB_TOKEN"), "unexpected error:\n{err}"); - } - - #[test] - fn ci_accepts_github_token() { - let result = validate_env(&[("CI", "true"), ("GITHUB_TOKEN", "token")]); - - assert!(result.is_ok(), "unexpected validation error: {result:?}"); - } - - #[test] - fn ci_accepts_github_com_token() { - let result = validate_env(&[("CI", "true"), ("GITHUB_COM_TOKEN", "token")]); - - assert!(result.is_ok(), "unexpected validation error: {result:?}"); - } - - #[test] - fn non_ci_missing_github_token_warns_without_failing() { - let warning = validate_env(&[]).unwrap().unwrap(); - - assert!(warning.contains("renovate-deps")); - assert!(warning.contains("GITHUB_TOKEN")); - } - - #[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 extracts_deps_from_current_renovate_message() { - let log = log_current( - 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("none of")); - assert!(err.to_string().contains(PACKAGE_FILES_MSGS[0])); - } - - #[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 display_path_normalizes_separators() { - let dir = tempfile::tempdir().unwrap(); - let path = dir - .path() - .join(".github") - .join("renovate-tracked-deps.json"); - assert_eq!( - display_path(dir.path(), &path), - ".github/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") - ); - } - - fn file_list(paths: &[&str], full: bool) -> FileList { - FileList { - files: paths.iter().map(PathBuf::from).collect(), - changed_paths: paths.iter().map(|path| path.to_string()).collect(), - merge_base: Some("base".to_string()), - full, - } - } - - #[test] - fn relevant_when_full_mode() { - let dir = tempfile::tempdir().unwrap(); - assert!(is_relevant(&file_list(&[], true), dir.path())); - } - - #[test] - fn relevant_when_renovate_config_changed() { - let dir = tempfile::tempdir().unwrap(); - assert!(is_relevant( - &file_list( - &[dir.path().join(".github/renovate.json5").to_str().unwrap()], - false - ), - dir.path() - )); - } - - #[test] - fn relevant_when_snapshot_changed() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join(".github")).unwrap(); - std::fs::write( - dir.path().join(".github/renovate-tracked-deps.json"), - "{}\n", - ) - .unwrap(); - - assert!(is_relevant( - &file_list( - &[dir - .path() - .join(".github/renovate-tracked-deps.json") - .to_str() - .unwrap()], - false - ), - dir.path() - )); - } - - #[test] - fn relevant_when_tracked_manifest_changed() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join(".github")).unwrap(); - write_snapshot( - &dir.path().join(".github/renovate-tracked-deps.json"), - &dep_map(&[("package.json", &[("npm", &["express"])])]), - ) - .unwrap(); - - assert!(is_relevant( - &file_list(&[dir.path().join("package.json").to_str().unwrap()], false), - dir.path() - )); - } - - #[test] - fn relevant_when_tracked_manifest_was_deleted() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join(".github")).unwrap(); - write_snapshot( - &dir.path().join(".github/renovate-tracked-deps.json"), - &dep_map(&[("package.json", &[("npm", &["express"])])]), - ) - .unwrap(); - - let file_list = FileList { - files: vec![], - changed_paths: vec!["package.json".to_string()], - merge_base: Some("base".to_string()), - full: false, - }; - - assert!(is_relevant(&file_list, dir.path())); - } - - #[test] - fn not_relevant_for_untracked_change() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join(".github")).unwrap(); - write_snapshot( - &dir.path().join(".github/renovate-tracked-deps.json"), - &dep_map(&[("package.json", &[("npm", &["express"])])]), - ) - .unwrap(); - - assert!(!is_relevant( - &file_list(&[dir.path().join("README.md").to_str().unwrap()], false), - dir.path() - )); - } - - #[test] - fn relevant_when_snapshot_is_unparseable() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join(".github")).unwrap(); - std::fs::write( - dir.path().join(".github/renovate-tracked-deps.json"), - "{not json}\n", - ) - .unwrap(); - - assert!(is_relevant( - &file_list(&[dir.path().join("README.md").to_str().unwrap()], false), - dir.path() - )); - } -} diff --git a/src/linters/renovate_deps/mod.rs b/src/linters/renovate_deps/mod.rs new file mode 100644 index 00000000..c6fae464 --- /dev/null +++ b/src/linters/renovate_deps/mod.rs @@ -0,0 +1,600 @@ +use anyhow::Context; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use self::rules::{ + comparable_package_rules_for_config, trim_snapshot_meta, validate_rule_coverage, +}; +use self::snapshot::{Snapshot, extract_deps, read_snapshot, unified_diff, write_snapshot}; +use crate::config::RenovateDepsConfig; +use crate::files::FileList; +use crate::linters::LinterOutput; +use crate::linters::env; +use crate::registry::{ + AdaptiveRelevanceContext, CheckTypeDef, InitHookContext, NativeCheckDef, NativePrepareContext, + NativeRunContext, NativeRunFuture, PreparedNativeCheck, +}; + +mod rules; +mod snapshot; + +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 RENOVATE_GITHUB_TOKEN_DISPLAY: &str = "GITHUB_COM_TOKEN or GITHUB_TOKEN"; + +pub(crate) static CHECK_TYPE: CheckTypeDef = CheckTypeDef::native_with_init_hook( + "renovate-deps", + NativeCheckDef::with_bin("renovate", prepare).with_fix(), + init, +); + +#[derive(Debug)] +struct PreparedRenovateDeps { + name: String, + cfg: RenovateDepsConfig, + tracked_files: Vec, +} + +fn prepare(ctx: NativePrepareContext<'_>) -> Option> { + Some(Box::new(PreparedRenovateDeps { + name: ctx.name.to_string(), + cfg: ctx.cfg.checks.renovate_deps.clone(), + tracked_files: COMMITTED_PATHS + .iter() + .map(|path| ctx.project_root.join(path)) + .collect(), + })) +} + +impl PreparedNativeCheck for PreparedRenovateDeps { + fn name(&self) -> &str { + &self.name + } + + fn tracked_files(&self) -> &[PathBuf] { + &self.tracked_files + } + + fn run(self: Box, ctx: NativeRunContext) -> NativeRunFuture { + Box::pin(async move { + crate::linters::renovate_deps::run(&self.cfg, ctx.fix, &ctx.project_root).await + }) + } +} + +pub async fn run(cfg: &RenovateDepsConfig, fix: 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 { + Ok(out) => out, + Err(e) => LinterOutput::err(format!("flint: renovate-deps: {e}\n")), + } +} + +pub(crate) fn init(ctx: &dyn InitHookContext) -> anyhow::Result { + let toml_path = ctx.config_dir().join("flint.toml"); + let config_changed = if let Some(managers) = ctx.renovate_exclude_managers() + && !managers.is_empty() + { + configure_renovate_deps_config(&toml_path, Some(managers))? + } else if ctx.flint_toml_generated() { + configure_renovate_deps_config(&toml_path, None)? + } else { + false + }; + let preset_changed = patch_renovate_preset(ctx.project_root())?; + Ok(config_changed || preset_changed) +} + +fn validate_runtime_env() -> Result, String> { + validate_runtime_env_from(|name| std::env::var(name).ok()) +} + +fn validate_runtime_env_from(env: F) -> Result, String> +where + F: Fn(&str) -> Option, +{ + if env::renovate_github_token_available(&env) { + return Ok(None); + } + if env::is_ci_from(&env) { + return Err(format!( + "flint: renovate-deps: missing required CI environment variable: {token_display}\n Set {github_token}, or set {github_com_token} directly, so Renovate can authenticate GitHub requests in CI.\n", + token_display = RENOVATE_GITHUB_TOKEN_DISPLAY, + github_com_token = env::GITHUB_COM_TOKEN_ENV, + github_token = env::GITHUB_TOKEN_ENV, + )); + } + Ok(Some(env::token_warning( + "renovate-deps", + RENOVATE_GITHUB_TOKEN_DISPLAY, + ))) +} + +pub(crate) fn is_relevant(file_list: &FileList, project_root: &Path) -> bool { + if file_list.full { + return true; + } + + let changed = changed_rel_paths(file_list, project_root); + + if changed.is_empty() { + return false; + } + + if changed + .iter() + .any(|path| RENOVATE_CONFIG_PATTERNS.contains(&path.as_str())) + { + return true; + } + + let committed_path = COMMITTED_PATHS + .iter() + .map(|path| project_root.join(path)) + .find(|path| path.exists()); + + let Some(committed_path) = committed_path else { + return false; + }; + + let committed_rel = display_path(project_root, &committed_path); + if changed.contains(&committed_rel) { + return true; + } + + let committed = match std::fs::read_to_string(&committed_path) + .ok() + .and_then(|contents| read_snapshot(&contents).ok()) + { + Some(committed) => committed, + None => return true, + }; + + committed.files.keys().any(|path| changed.contains(path)) +} + +fn changed_rel_paths(file_list: &FileList, project_root: &Path) -> HashSet { + if !file_list.changed_paths.is_empty() { + return file_list + .changed_paths + .iter() + .map(|path| { + let path = Path::new(path); + path.strip_prefix(project_root).unwrap_or(path) + }) + .map(normalize_path) + .collect(); + } + + file_list + .files + .iter() + .filter_map(|path| path.strip_prefix(project_root).ok()) + .map(normalize_path) + .collect() +} + +fn normalize_path(path: &Path) -> String { + path.components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + +pub(crate) fn adaptive_relevance(ctx: &dyn AdaptiveRelevanceContext) -> bool { + is_relevant(ctx.file_list(), ctx.project_root()) +} + +/// Ensures `flint.toml` has the Renovate check config requested by init. +/// Returns `true` when the file was changed. +fn configure_renovate_deps_config( + toml_path: &Path, + exclude_managers: Option<&[String]>, +) -> anyhow::Result { + let content = std::fs::read_to_string(toml_path) + .with_context(|| format!("failed to read {}", toml_path.display()))?; + let mut doc: toml_edit::DocumentMut = content.parse().context("failed to parse flint.toml")?; + let Some(checks) = doc.get("checks").and_then(|item| item.as_table()) else { + return append_renovate_deps_config(toml_path, &content, exclude_managers); + }; + let Some(table_key) = ["renovate-deps", "renovate_deps"] + .into_iter() + .find(|key| checks.contains_key(key)) + else { + return append_renovate_deps_config(toml_path, &content, exclude_managers); + }; + + let Some(managers) = exclude_managers.filter(|managers| !managers.is_empty()) else { + return Ok(false); + }; + let renovate = doc + .get_mut("checks") + .and_then(|item| item.as_table_mut()) + .and_then(|checks| checks.get_mut(table_key)) + .and_then(|item| item.as_table_mut()) + .with_context(|| { + format!( + "[checks.{table_key}] is not a table in {}", + toml_path.display() + ) + })?; + if renovate.contains_key("exclude_managers") { + return Ok(false); + } + renovate.insert("exclude_managers", toml_edit::value(string_array(managers))); + std::fs::write(toml_path, doc.to_string()) + .with_context(|| format!("failed to write {}", toml_path.display()))?; + println!( + " patched {} — added checks.renovate-deps.exclude_managers", + toml_path.display() + ); + Ok(true) +} + +fn append_renovate_deps_config( + toml_path: &Path, + content: &str, + exclude_managers: Option<&[String]>, +) -> anyhow::Result { + let mut next = String::from(content); + if !next.ends_with('\n') { + next.push('\n'); + } + next.push_str("\n[checks.renovate-deps]\n"); + match exclude_managers { + Some(managers) if !managers.is_empty() => { + next.push_str(&format!("exclude_managers = {}\n", string_array(managers))); + } + _ => next.push_str("# exclude_managers = []\n"), + } + std::fs::write(toml_path, next) + .with_context(|| format!("failed to write {}", toml_path.display()))?; + println!( + " patched {} — added checks.renovate-deps", + toml_path.display() + ); + Ok(true) +} + +fn string_array(values: &[String]) -> toml_edit::Array { + let mut array = toml_edit::Array::default(); + for value in values { + array.push(value.as_str()); + } + array +} + +fn patch_renovate_preset(project_root: &Path) -> anyhow::Result { + let Some(path) = find_renovate_config(project_root) else { + return Ok(false); + }; + let changed = patch_renovate_extends(&path)?; + if changed { + let rel = path.strip_prefix(project_root).unwrap_or(&path); + println!(" patched {} — added {}", rel.display(), flint_preset()); + } + Ok(changed) +} + +fn find_renovate_config(project_root: &Path) -> Option { + RENOVATE_CONFIG_PATTERNS + .iter() + .map(|path| project_root.join(path)) + .find(|path| path.exists()) +} + +/// 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. +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. +fn patch_renovate_extends(path: &Path) -> anyhow::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) -> anyhow::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(|line| !line.trim().is_empty()) + .map(|line| " ".repeat(line.len() - line.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); + let separator = if after.trim() == "}" { "" } else { "," }; + Ok(format!( + "{}\n \"extends\": [\"{}\"]{}{}", + before, entry, separator, after + )) + } +} + +async fn run_inner( + cfg: &RenovateDepsConfig, + fix: bool, + project_root: &Path, +) -> anyhow::Result { + let config_path = resolve_renovate_config_path(project_root)?; + let parsed_rules = comparable_package_rules_for_config(&config_path)?; + let rules = parsed_rules.rules; + 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); + + if committed.is_none() { + 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, + }); + } + return Ok(LinterOutput::err(format!( + "ERROR: {committed_display} does not exist.\nRun `flint run --fix renovate-deps` to create it.\n" + ))); + } + + let committed = committed.expect("checked above"); + + if 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 { + ok: true, + stdout, + stderr: vec![], + setup_outcome: None, + }); + } + + 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, + }); + } + + 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(), + setup_outcome: None, + }) +} + +fn notes_output(notes: &[String]) -> String { + if notes.is_empty() { + return String::new(); + } + + format!("{}\n", notes.join("\n")) +} + +/// 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(env::GITHUB_COM_TOKEN_ENV) + .map(|v| !v.is_empty()) + .unwrap_or(false); + if !has_com_token + && let Ok(token) = std::env::var(env::GITHUB_TOKEN_ENV) + && !token.is_empty() + { + env.push((env::GITHUB_COM_TOKEN_ENV.into(), token)); + } + + let out = super::spawn_command( + &[ + "renovate".to_string(), + "--platform=local".to_string(), + "--require-config=ignored".to_string(), + "--dry-run=extract".to_string(), + ], + false, + ) + .current_dir(project_root) + .envs(env) + .stdin(Stdio::null()) + .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 { + normalize_path(path.strip_prefix(project_root).unwrap_or(path)) +} + +fn merge_missing_meta_from_committed(generated: &mut Snapshot, committed: &Snapshot) { + for (dep_name, generated_meta) in &mut generated.meta { + let Some(committed_meta) = committed.meta.get(dep_name) else { + continue; + }; + if generated_meta.package_name.is_none() { + generated_meta.package_name = committed_meta.package_name.clone(); + } + if generated_meta.datasource.is_none() { + generated_meta.datasource = committed_meta.datasource.clone(); + } + } +} + +fn maybe_reuse_committed_meta( + generated: &mut Snapshot, + committed: Option<&Snapshot>, + refresh_meta: bool, +) { + if let Some(committed) = committed + && !refresh_meta + { + merge_missing_meta_from_committed(generated, committed); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/linters/renovate_deps/rules.rs b/src/linters/renovate_deps/rules.rs new file mode 100644 index 00000000..2cb863c2 --- /dev/null +++ b/src/linters/renovate_deps/rules.rs @@ -0,0 +1,308 @@ +use anyhow::Context; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use super::snapshot::Snapshot; + +const MATCH_PREFIX: &str = "match"; +const MATCH_DEP_NAMES: &str = "matchDepNames"; +const MATCH_PACKAGE_NAMES: &str = "matchPackageNames"; +const CONTEXTUAL_MATCHERS: &[&str] = &[ + "matchCategories", + "matchDatasources", + "matchDepTypes", + "matchFileNames", + "matchManagers", + "matchPaths", + "matchRepositories", + "matchSourceUrls", +]; + +#[derive(Debug)] +pub(crate) enum RuleMatcher { + DepNames(BTreeSet), + PackageNames(BTreeSet), +} + +#[derive(Debug)] +pub(crate) struct ComparablePackageRule { + pub(crate) label: String, + pub(crate) matcher: RuleMatcher, +} + +#[derive(Debug)] +pub(crate) struct ComparablePackageRules { + pub(crate) rules: Vec, + pub(crate) skipped_notes: Vec, +} + +pub(crate) fn comparable_package_rules_for_config( + config_path: &Path, +) -> anyhow::Result { + let config = std::fs::read_to_string(config_path) + .with_context(|| format!("failed to read {}", config_path.display()))?; + let parsed: serde_json::Value = json5::from_str(&config) + .with_context(|| format!("failed to parse {}", config_path.display()))?; + + Ok(parsed["packageRules"] + .as_array() + .map(|rules| { + rules + .iter() + .enumerate() + .map(|(idx, rule)| comparable_package_rule(rule, idx)) + .collect::>>() + .map(|rules| { + let mut comparable = Vec::new(); + let mut skipped_notes = Vec::new(); + for rule in rules.into_iter().flatten() { + match rule { + ComparableRuleOutcome::Comparable(rule) => comparable.push(rule), + ComparableRuleOutcome::Skipped { note } => skipped_notes.push(note), + } + } + ComparablePackageRules { + rules: comparable, + skipped_notes, + } + }) + }) + .transpose()? + .unwrap_or_else(|| ComparablePackageRules { + rules: Vec::new(), + skipped_notes: Vec::new(), + })) +} + +pub(crate) fn validate_rule_coverage( + snapshot: &Snapshot, + rules: &[ComparablePackageRule], +) -> anyhow::Result<()> { + let package_groups = package_groups(snapshot); + let mut errors = vec![]; + + for ((package_name, datasource), deps) in package_groups { + if deps.len() < 2 { + continue; + } + for rule in rules { + let matched: Vec<_> = deps + .iter() + .filter(|dep| rule.matches(dep, snapshot)) + .collect(); + if matched.is_empty() || matched.len() == deps.len() { + continue; + } + let matched = matched + .into_iter() + .map(|dep| dep.as_str()) + .collect::>() + .join(", "); + let unmatched = deps + .iter() + .filter(|dep| !rule.matches(dep, snapshot)) + .map(String::as_str) + .collect::>() + .join(", "); + errors.push(format!( + "package rule {} matches package {} inconsistently: matched [{}], unmatched [{}] (datasource {})", + rule.label, + package_name, + matched, + unmatched, + datasource.as_deref().unwrap_or("unknown"), + )); + } + } + + if errors.is_empty() { + Ok(()) + } else { + anyhow::bail!(errors.join("\n")) + } +} + +pub(crate) fn trim_snapshot_meta(snapshot: &mut Snapshot, rules: &[ComparablePackageRule]) { + let relevant = relevant_dep_names(snapshot, rules); + snapshot + .meta + .retain(|dep_name, _| relevant.contains(dep_name)); +} + +enum ComparableRuleOutcome { + Comparable(ComparablePackageRule), + Skipped { note: String }, +} + +fn comparable_package_rule( + rule: &serde_json::Value, + idx: usize, +) -> anyhow::Result> { + let extra_matchers: Vec<_> = rule + .as_object() + .into_iter() + .flat_map(|obj| obj.keys()) + .filter(|key| key.starts_with(MATCH_PREFIX)) + .filter(|key| *key != MATCH_DEP_NAMES && *key != MATCH_PACKAGE_NAMES) + .cloned() + .collect(); + let contextual_matchers: Vec<_> = extra_matchers + .iter() + .filter(|key| requires_contextual_matching(key)) + .cloned() + .collect(); + + let dep_names = optional_matcher_values(rule, idx, MATCH_DEP_NAMES)?; + let package_names = optional_matcher_values(rule, idx, MATCH_PACKAGE_NAMES)?; + + let label = rule_label(rule, idx); + + match (&dep_names, &package_names) { + (Some(_), Some(_)) => { + anyhow::bail!( + "package rule {label} declares both matchDepNames and matchPackageNames; flint requires exactly one for rule-coverage checks" + ); + } + (None, None) => return Ok(None), + _ => {} + } + + if !contextual_matchers.is_empty() { + return Ok(Some(ComparableRuleOutcome::Skipped { + note: format!( + "skipped package rule {label} for coverage validation because it uses context-sensitive matchers [{}]", + contextual_matchers.join(", "), + ), + })); + } + + let matcher = if let Some(names) = dep_names { + RuleMatcher::DepNames(names) + } else if let Some(names) = package_names { + RuleMatcher::PackageNames(names) + } else { + unreachable!("handled by the match above") + }; + + Ok(Some(ComparableRuleOutcome::Comparable( + ComparablePackageRule { label, matcher }, + ))) +} + +fn rule_label(rule: &serde_json::Value, idx: usize) -> String { + rule["groupName"] + .as_str() + .map(|group| format!("group {group:?}")) + .or_else(|| { + rule["description"] + .as_str() + .map(|desc| format!("description {desc:?}")) + }) + .unwrap_or_else(|| format!("index {idx}")) +} + +fn optional_matcher_values( + rule: &serde_json::Value, + idx: usize, + key: &'static str, +) -> anyhow::Result>> { + let Some(value) = rule.get(key) else { + return Ok(None); + }; + + let names = value.as_array().ok_or_else(|| { + anyhow::anyhow!("package rule index {idx} must declare {key} as an array") + })?; + + let mut out = BTreeSet::new(); + for (name_idx, value) in names.iter().enumerate() { + let name = value.as_str().ok_or_else(|| { + anyhow::anyhow!("package rule index {idx} must declare {key}[{name_idx}] as a string") + })?; + out.insert(name.to_string()); + } + Ok(Some(out)) +} + +fn requires_contextual_matching(key: &str) -> bool { + CONTEXTUAL_MATCHERS.contains(&key) +} + +impl ComparablePackageRule { + fn matches(&self, dep_name: &str, snapshot: &Snapshot) -> bool { + match &self.matcher { + RuleMatcher::DepNames(names) => names.contains(dep_name), + RuleMatcher::PackageNames(names) => snapshot + .meta + .get(dep_name) + .and_then(|meta| meta.package_name.as_deref()) + .is_some_and(|package_name| names.contains(package_name)), + } + } +} + +fn package_groups(snapshot: &Snapshot) -> BTreeMap<(String, Option), BTreeSet> { + let mut groups = BTreeMap::new(); + for dep_name in snapshot + .files + .values() + .flat_map(|managers| managers.values()) + .flatten() + { + let Some(meta) = snapshot.meta.get(dep_name) else { + continue; + }; + let Some(package_name) = meta.package_name.as_ref() else { + continue; + }; + groups + .entry((package_name.clone(), meta.datasource.clone())) + .or_insert_with(BTreeSet::new) + .insert(dep_name.clone()); + } + groups +} + +pub(crate) fn relevant_dep_names( + snapshot: &Snapshot, + rules: &[ComparablePackageRule], +) -> BTreeSet { + let mut relevant = BTreeSet::new(); + let extracted_dep_names: BTreeSet<_> = snapshot + .files + .values() + .flat_map(|managers| managers.values()) + .flatten() + .cloned() + .collect(); + + for rule in rules { + match &rule.matcher { + RuleMatcher::DepNames(names) => { + relevant.extend( + names + .iter() + .filter(|dep_name| extracted_dep_names.contains(*dep_name)) + .cloned(), + ); + } + RuleMatcher::PackageNames(names) => { + relevant.extend(snapshot.meta.iter().filter_map(|(dep_name, meta)| { + meta.package_name + .as_deref() + .is_some_and(|package_name| names.contains(package_name)) + .then_some(dep_name.clone()) + })); + } + } + } + + let package_groups = package_groups(snapshot); + for deps in package_groups.values() { + if deps.iter().any(|dep_name| relevant.contains(dep_name)) { + relevant.extend(deps.iter().cloned()); + } + } + + relevant +} diff --git a/src/linters/renovate_deps/snapshot.rs b/src/linters/renovate_deps/snapshot.rs new file mode 100644 index 00000000..fce76314 --- /dev/null +++ b/src/linters/renovate_deps/snapshot.rs @@ -0,0 +1,192 @@ +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::path::Path; + +const PACKAGE_FILES_MSGS: &[&str] = &["Extracted dependencies", "packageFiles with updates"]; +const SKIP_REASONS: &[&str] = &["contains-variable", "invalid-value", "invalid-version"]; + +/// `{file_path: {manager: [dep_name, ...]}}` — all collections sorted. +pub(crate) type DepFiles = BTreeMap>>; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub(crate) struct Snapshot { + pub(crate) meta: BTreeMap, + pub(crate) files: DepFiles, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub(crate) struct DepMeta { + #[serde(rename = "packageName", skip_serializing_if = "Option::is_none")] + pub(crate) package_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) datasource: Option, +} + +impl Snapshot { + pub(crate) fn is_empty(&self) -> bool { + self.files.is_empty() + } +} + +pub(crate) fn read_snapshot(contents: &str) -> anyhow::Result { + let parsed: serde_json::Value = serde_json::from_str(contents)?; + if parsed.get("files").is_some() || parsed.get("meta").is_some() { + return Ok(serde_json::from_value(parsed)?); + } + Ok(Snapshot { + meta: BTreeMap::new(), + files: serde_json::from_value(parsed)?, + }) +} + +/// Parses Renovate's NDJSON log and returns the dependency snapshot. +pub(crate) 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()) + .is_some_and(|msg| PACKAGE_FILES_MSGS.contains(&msg)) + { + let extracted_config = entry + .get("packageFiles") + .cloned() + .or_else(|| entry.get("config").cloned()); + if extracted_config.is_some() { + config_obj = extracted_config; + } + } + } + + let config = config_obj + .ok_or_else(|| anyhow::anyhow!("none of {:?} found in Renovate log", PACKAGE_FILES_MSGS))?; + + let mut deps_by_file: BTreeMap>> = BTreeMap::new(); + let mut meta_by_dep: 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; + }; + let next_meta = DepMeta { + package_name: dep + .get("packageName") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned), + datasource: dep + .get("datasource") + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned), + }; + merge_dep_meta( + meta_by_dep.entry(dep_name.to_string()).or_default(), + &next_meta, + ) + .with_context(|| { + format!("conflicting metadata extracted for dependency {dep_name:?}") + })?; + 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. + let files = 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(); + + Ok(Snapshot { + meta: meta_by_dep, + files, + }) +} + +fn merge_dep_meta(existing: &mut DepMeta, next: &DepMeta) -> anyhow::Result<()> { + merge_optional_field( + &mut existing.package_name, + &next.package_name, + "packageName", + )?; + merge_optional_field(&mut existing.datasource, &next.datasource, "datasource")?; + Ok(()) +} + +fn merge_optional_field( + existing: &mut Option, + next: &Option, + field: &str, +) -> anyhow::Result<()> { + match (existing.as_deref(), next.as_deref()) { + (Some(left), Some(right)) if left != right => { + anyhow::bail!("conflicting {field}: {left:?} != {right:?}"); + } + (None, Some(value)) => *existing = Some(value.to_string()), + _ => {} + } + Ok(()) +} + +pub(crate) fn write_snapshot(path: &Path, deps: &Snapshot) -> anyhow::Result<()> { + let json = serde_json::to_string_pretty(deps)?; + std::fs::write(path, json + "\n")?; + Ok(()) +} + +pub(crate) fn unified_diff(old: &Snapshot, new: &Snapshot, 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() +} diff --git a/src/linters/renovate_deps/tests.rs b/src/linters/renovate_deps/tests.rs new file mode 100644 index 00000000..edec7df4 --- /dev/null +++ b/src/linters/renovate_deps/tests.rs @@ -0,0 +1,857 @@ +use super::rules::{ComparablePackageRule, RuleMatcher, relevant_dep_names}; +use super::snapshot::{DepFiles, DepMeta}; +use super::*; +use std::collections::BTreeSet; + +type FileManagers<'a> = [(&'a str, &'a [(&'a str, &'a [&'a str])])]; + +fn log(config_json: &str) -> Vec { + format!(r#"{{"msg":"Extracted dependencies","packageFiles":{config_json}}}"#).into_bytes() +} + +fn log_current(config_json: &str) -> Vec { + format!(r#"{{"msg":"packageFiles with updates","config":{config_json}}}"#).into_bytes() +} + +fn dep_files(entries: &FileManagers<'_>) -> DepFiles { + 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() +} + +fn snapshot(meta: &[(&str, Option<&str>, Option<&str>)], files: &FileManagers<'_>) -> Snapshot { + Snapshot { + meta: meta + .iter() + .map(|(dep, package_name, datasource)| { + ( + dep.to_string(), + DepMeta { + package_name: package_name.map(ToOwned::to_owned), + datasource: datasource.map(ToOwned::to_owned), + }, + ) + }) + .collect(), + files: dep_files(files), + } +} + +fn validate_env(vars: &[(&str, &str)]) -> Result, String> { + let vars: std::collections::HashMap = vars + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect(); + validate_runtime_env_from(|name| vars.get(name).cloned()) +} + +fn write_tmp(content: &str) -> tempfile::NamedTempFile { + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), content).unwrap(); + file +} + +#[test] +fn configure_renovate_deps_appends_placeholder() { + let tmp = write_tmp("[settings]\n"); + let changed = configure_renovate_deps_config(tmp.path(), None).unwrap(); + assert!(changed); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(result.contains("[checks.renovate-deps]")); + assert!(result.contains("# exclude_managers = []")); +} + +#[test] +fn configure_renovate_deps_appends_migrated_managers() { + let tmp = write_tmp("[settings]\n"); + let managers = vec!["github-actions".to_string(), "cargo".to_string()]; + let changed = configure_renovate_deps_config(tmp.path(), Some(&managers)).unwrap(); + assert!(changed); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!( + result.contains("exclude_managers = [\"github-actions\", \"cargo\"]"), + "managers written uncommented: {result}" + ); + assert!(!result.contains("# exclude_managers")); +} + +#[test] +fn configure_renovate_deps_keeps_existing_managers() { + let tmp = write_tmp("[checks.renovate-deps]\nexclude_managers = [\"npm\"]\n"); + let managers = vec!["github-actions".to_string(), "cargo".to_string()]; + let changed = configure_renovate_deps_config(tmp.path(), Some(&managers)).unwrap(); + assert!(!changed); + let result = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(result.contains("exclude_managers = [\"npm\"]")); + assert!(!result.contains("github-actions")); +} + +#[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}" + ); + 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 = 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\",")); + 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_extends_when_absent_in_empty_object() { + let input = "{}\n"; + let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); + assert_eq!( + result, + "{\n \"extends\": [\"github>grafana/flint#v0.9.2\"]}\n" + ); +} + +#[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"]"#)); +} + +#[test] +fn ci_requires_github_token_or_github_com_token() { + let err = validate_env(&[("CI", "true")]).unwrap_err(); + + assert!(err.contains("GITHUB_COM_TOKEN"), "unexpected error:\n{err}"); + assert!(err.contains("GITHUB_TOKEN"), "unexpected error:\n{err}"); +} + +#[test] +fn ci_accepts_github_token() { + let result = validate_env(&[("CI", "true"), ("GITHUB_TOKEN", "token")]); + + assert!(result.is_ok(), "unexpected validation error: {result:?}"); +} + +#[test] +fn ci_accepts_github_com_token() { + let result = validate_env(&[("CI", "true"), ("GITHUB_COM_TOKEN", "token")]); + + assert!(result.is_ok(), "unexpected validation error: {result:?}"); +} + +#[test] +fn non_ci_missing_github_token_warns_without_failing() { + let warning = validate_env(&[]).unwrap().unwrap(); + + assert!(warning.contains("renovate-deps")); + assert!(warning.contains("GITHUB_TOKEN")); +} + +#[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, + snapshot( + &[("express", None, None), ("lodash", None, None),], + &[("package.json", &[("npm", &["express", "lodash"])])], + ) + ); +} + +#[test] +fn extracts_deps_from_current_renovate_message() { + let log = log_current( + r#"{"npm":[{"packageFile":"package.json","deps":[{"depName":"express"},{"depName":"lodash"}]}]}"#, + ); + let result = extract_deps(&log, &[]).unwrap(); + assert_eq!( + result, + snapshot( + &[("express", None, None), ("lodash", None, None),], + &[("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.files["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.files["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.files["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.files.contains_key("package.json")); + assert_eq!(result.files["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.files["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.files.contains_key("a.json"), + "should use last entry" + ); + assert!(result.files.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.files.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("none of")); + assert!(err.to_string().contains("Extracted dependencies")); +} + +#[test] +fn write_and_read_snapshot_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.json"); + let deps = snapshot( + &[ + ("serde", None, None), + ("tokio", None, None), + ("express", None, None), + ("lodash", None, None), + ], + &[ + ("Cargo.toml", &[("cargo", &["serde", "tokio"])]), + ("package.json", &[("npm", &["express", "lodash"])]), + ], + ); + write_snapshot(&path, &deps).unwrap(); + let read_back = read_snapshot(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(deps, read_back); +} + +#[test] +fn reads_legacy_snapshot_format() { + let legacy = r#"{ + "package.json": { + "npm": [ + "express" + ] + } +} +"#; + let snapshot = read_snapshot(legacy).unwrap(); + assert!(snapshot.meta.is_empty()); + assert_eq!( + snapshot.files, + dep_files(&[("package.json", &[("npm", &["express"])])]) + ); +} + +#[test] +fn write_snapshot_ends_with_newline() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.json"); + write_snapshot(&path, &Snapshot::default()).unwrap(); + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.ends_with('\n')); +} + +#[test] +fn merge_missing_meta_from_committed_keeps_existing_details() { + let mut generated = snapshot( + &[("actionlint", None, Some("github-releases"))], + &[("mise.toml", &[("mise", &["actionlint"])])], + ); + let committed = snapshot( + &[( + "actionlint", + Some("rhysd/actionlint"), + Some("github-releases"), + )], + &[("mise.toml", &[("mise", &["actionlint"])])], + ); + + merge_missing_meta_from_committed(&mut generated, &committed); + + assert_eq!( + generated.meta["actionlint"].package_name.as_deref(), + Some("rhysd/actionlint") + ); + assert_eq!( + generated.meta["actionlint"].datasource.as_deref(), + Some("github-releases") + ); +} + +#[test] +fn maybe_reuse_committed_meta_merges_when_refresh_meta_is_disabled() { + let mut generated = snapshot( + &[("actionlint", None, Some("github-releases"))], + &[("mise.toml", &[("mise", &["actionlint"])])], + ); + let committed = snapshot( + &[( + "actionlint", + Some("rhysd/actionlint"), + Some("github-releases"), + )], + &[("mise.toml", &[("mise", &["actionlint"])])], + ); + + maybe_reuse_committed_meta(&mut generated, Some(&committed), false); + + assert_eq!( + generated.meta["actionlint"].package_name.as_deref(), + Some("rhysd/actionlint") + ); +} + +#[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( + &[( + "actionlint", + Some("rhysd/actionlint"), + Some("github-releases"), + )], + &[("mise.toml", &[("mise", &["actionlint"])])], + ); + + maybe_reuse_committed_meta(&mut generated, Some(&committed), true); + + assert_eq!(generated.meta["actionlint"].package_name, None); +} + +#[test] +fn validate_rule_coverage_flags_split_dep_names_for_same_package() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("renovate.json5"); + std::fs::write( + &config_path, + r#"{ + packageRules: [ + { + groupName: "linters", + matchDepNames: ["actionlint"] + } + ] +} +"#, + ) + .unwrap(); + let snapshot = snapshot( + &[ + ( + "actionlint", + Some("rhysd/actionlint"), + Some("github-releases"), + ), + ( + "rhysd/actionlint", + Some("rhysd/actionlint"), + Some("github-releases"), + ), + ], + &[ + ("mise.toml", &[("mise", &["actionlint"])]), + ("README.md", &[("regex", &["rhysd/actionlint"])]), + ], + ); + + let parsed = comparable_package_rules_for_config(&config_path).unwrap(); + let err = validate_rule_coverage(&snapshot, &parsed.rules).unwrap_err(); + let msg = err.to_string(); + + assert!(msg.contains("rhysd/actionlint")); + assert!(msg.contains("matched [actionlint]")); + assert!(msg.contains("unmatched [rhysd/actionlint]")); +} + +#[test] +fn comparable_rules_reject_non_string_match_dep_names() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("renovate.json5"); + std::fs::write( + &config_path, + r#"{ + packageRules: [ + { + groupName: "linters", + matchDepNames: ["actionlint", 42] + } + ] +} +"#, + ) + .unwrap(); + + let err = comparable_package_rules_for_config(&config_path).unwrap_err(); + + assert!( + err.to_string() + .contains("package rule index 0 must declare matchDepNames[1] as a string") + ); +} + +#[test] +fn comparable_rules_reject_non_string_match_package_names() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("renovate.json5"); + std::fs::write( + &config_path, + r#"{ + packageRules: [ + { + description: "packages", + matchPackageNames: [false] + } + ] +} +"#, + ) + .unwrap(); + + let err = comparable_package_rules_for_config(&config_path).unwrap_err(); + + assert!( + err.to_string() + .contains("package rule index 0 must declare matchPackageNames[0] as a string") + ); +} + +#[test] +fn comparable_rules_reject_additional_match_constraints() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("renovate.json5"); + std::fs::write( + &config_path, + r#"{ + packageRules: [ + { + groupName: "linters", + matchDepNames: ["actionlint"], + matchManagers: ["custom.regex"] + } + ] +} +"#, + ) + .unwrap(); + + let parsed = comparable_package_rules_for_config(&config_path).unwrap(); + + assert!(parsed.rules.is_empty()); + assert_eq!(parsed.skipped_notes.len(), 1); + assert!(parsed.skipped_notes[0].contains("group \"linters\"")); + assert!(parsed.skipped_notes[0].contains("matchManagers")); + assert!(parsed.skipped_notes[0].contains("skipped package rule")); +} + +#[test] +fn comparable_rules_allow_non_contextual_match_constraints() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("renovate.json5"); + std::fs::write( + &config_path, + r#"{ + packageRules: [ + { + description: "slim tags", + matchPackageNames: ["ghcr.io/super-linter/super-linter"], + matchCurrentValue: "/^slim-/" + } + ] +} +"#, + ) + .unwrap(); + + let parsed = comparable_package_rules_for_config(&config_path).unwrap(); + + assert_eq!(parsed.rules.len(), 1); + assert!(parsed.skipped_notes.is_empty()); +} + +#[test] +fn notes_output_formats_skipped_rule_messages() { + let out = notes_output(&[ + "first skipped note".to_string(), + "second skipped note".to_string(), + ]); + + assert_eq!(out, "first skipped note\nsecond skipped note\n"); +} + +#[test] +fn trim_snapshot_meta_keeps_only_rule_relevant_deps() { + let snapshot = snapshot( + &[ + ( + "actionlint", + Some("rhysd/actionlint"), + Some("github-releases"), + ), + ( + "rhysd/actionlint", + Some("rhysd/actionlint"), + Some("github-releases"), + ), + ( + "Swatinem/rust-cache", + Some("Swatinem/rust-cache"), + Some("github-tags"), + ), + ], + &[ + ("mise.toml", &[("mise", &["actionlint"])]), + ( + "src/init/scaffold.rs", + &[("regex", &["Swatinem/rust-cache"])], + ), + ("README.md", &[("regex", &["rhysd/actionlint"])]), + ], + ); + let rules = vec![ComparablePackageRule { + label: "group \"linters\"".to_string(), + matcher: RuleMatcher::DepNames(BTreeSet::from(["actionlint".to_string()])), + }]; + + let relevant = relevant_dep_names(&snapshot, &rules); + + assert!(relevant.contains("actionlint")); + assert!(relevant.contains("rhysd/actionlint")); + assert!(!relevant.contains("Swatinem/rust-cache")); +} + +#[test] +fn unified_diff_contains_added_and_removed_lines() { + let old = snapshot( + &[("old-dep", None, None)], + &[("a.json", &[("npm", &["old-dep"])])], + ); + let new = snapshot( + &[("new-dep", None, None)], + &[("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 = snapshot(&[("x", None, None)], &[("a.json", &[("npm", &["x"])])]); + let new = snapshot(&[("y", None, None)], &[("a.json", &[("npm", &["y"])])]); + let diff = unified_diff(&old, &new, "renovate-tracked-deps.json"); + assert!(diff.contains("renovate-tracked-deps.json")); +} + +#[test] +fn display_path_normalizes_separators() { + let dir = tempfile::tempdir().unwrap(); + let path = dir + .path() + .join(".github") + .join("renovate-tracked-deps.json"); + assert_eq!( + display_path(dir.path(), &path), + ".github/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") + ); +} + +fn file_list(paths: &[&str], full: bool) -> FileList { + FileList { + files: paths.iter().map(PathBuf::from).collect(), + changed_paths: paths.iter().map(|path| path.to_string()).collect(), + merge_base: Some("base".to_string()), + full, + } +} + +#[test] +fn relevant_when_full_mode() { + let dir = tempfile::tempdir().unwrap(); + assert!(is_relevant(&file_list(&[], true), dir.path())); +} + +#[test] +fn relevant_when_renovate_config_changed() { + let dir = tempfile::tempdir().unwrap(); + assert!(is_relevant( + &file_list( + &[dir.path().join(".github/renovate.json5").to_str().unwrap()], + false + ), + dir.path() + )); +} + +#[test] +fn relevant_when_snapshot_changed() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".github")).unwrap(); + std::fs::write( + dir.path().join(".github/renovate-tracked-deps.json"), + "{}\n", + ) + .unwrap(); + + assert!(is_relevant( + &file_list( + &[dir + .path() + .join(".github/renovate-tracked-deps.json") + .to_str() + .unwrap()], + false + ), + dir.path() + )); +} + +#[test] +fn relevant_when_tracked_manifest_changed() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".github")).unwrap(); + write_snapshot( + &dir.path().join(".github/renovate-tracked-deps.json"), + &snapshot( + &[("express", None, None)], + &[("package.json", &[("npm", &["express"])])], + ), + ) + .unwrap(); + + assert!(is_relevant( + &file_list(&[dir.path().join("package.json").to_str().unwrap()], false), + dir.path() + )); +} + +#[test] +fn relevant_when_tracked_manifest_was_deleted() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".github")).unwrap(); + write_snapshot( + &dir.path().join(".github/renovate-tracked-deps.json"), + &snapshot( + &[("express", None, None)], + &[("package.json", &[("npm", &["express"])])], + ), + ) + .unwrap(); + + let file_list = FileList { + files: vec![], + changed_paths: vec!["package.json".to_string()], + merge_base: Some("base".to_string()), + full: false, + }; + + assert!(is_relevant(&file_list, dir.path())); +} + +#[test] +fn not_relevant_for_untracked_change() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".github")).unwrap(); + write_snapshot( + &dir.path().join(".github/renovate-tracked-deps.json"), + &snapshot( + &[("express", None, None)], + &[("package.json", &[("npm", &["express"])])], + ), + ) + .unwrap(); + + assert!(!is_relevant( + &file_list(&[dir.path().join("README.md").to_str().unwrap()], false), + dir.path() + )); +} + +#[test] +fn relevant_when_snapshot_is_unparseable() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".github")).unwrap(); + std::fs::write( + dir.path().join(".github/renovate-tracked-deps.json"), + "{not json}\n", + ) + .unwrap(); + + assert!(is_relevant( + &file_list(&[dir.path().join("README.md").to_str().unwrap()], false), + dir.path() + )); +} diff --git a/src/registry/checks.rs b/src/registry/checks.rs index ac437ae1..56cb4054 100644 --- a/src/registry/checks.rs +++ b/src/registry/checks.rs @@ -428,8 +428,13 @@ fn check_renovate_deps() -> Check { .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\n\ - Renovate locally and comparing its output against the committed snapshot.\n\ + "Verifies `renovate-tracked-deps.json` next to the active Renovate\n\ + config is up to date by running Renovate locally and comparing its\n\ + output against the committed snapshot.\n\ + It also checks that dependencies extracted from different files but\n\ + resolving to the same upstream package match the same Renovate\n\ + package rules. That catches config splits like `actionlint` vs\n\ + `rhysd/actionlint` before Renovate stops grouping them consistently.\n\ Requires `renovate` in `[tools]`.\n\ \n\ In CI, `renovate-deps` requires `GITHUB_COM_TOKEN` or `GITHUB_TOKEN`\n\ @@ -441,6 +446,10 @@ fn check_renovate_deps() -> Check { legacy `RENOVATE_TRACKED_DEPS_EXCLUDE` values into `exclude_managers`.\n\ \n\ With `--fix`, automatically regenerates and commits the snapshot.\n\ + For custom/regex managers, prefer canonical `depNameTemplate` values\n\ + for grouping and explicit `packageNameTemplate` values for datasource\n\ + lookups when those identities differ.\n\ + See [the renovate-deps guide](linters/renovate-deps.md) for examples.\n\ \n\ Configure via `flint.toml`:\n\ \n\ diff --git a/src/registry/tests.rs b/src/registry/tests.rs index 77596d04..641408ae 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -396,7 +396,7 @@ fn default_renovate_preset_covers_all_linter_tools_weekly() { .find(|rule| rule["groupName"].as_str() == Some("linters")) .expect("default.json must define a packageRules entry with groupName 'linters'"); - let actual = package_names(linters_rule); + let actual = dep_names(linters_rule); let expected: Vec<&str> = builtin() .into_iter() .filter(|check| check.uses_binary()) @@ -412,8 +412,8 @@ fn default_renovate_preset_covers_all_linter_tools_weekly() { ); assert_eq!( actual, - sorted_package_names(linters_rule), - "default.json weekly linters rule matchPackageNames must be sorted" + sorted_dep_names(linters_rule), + "default.json weekly linters rule matchDepNames must be sorted" ); assert_eq!( @@ -478,14 +478,19 @@ fn repo_renovate_config_stays_aligned_with_shared_preset_contract() { "package rule {group_name:?} separateMajorMinor in .github/renovate.json5 drifted from default.json" ); assert_eq!( - package_names(default_rule), - package_names(repo_rule), - "package rule {group_name:?} matchPackageNames in .github/renovate.json5 drifted from default.json" + rule_name_field(default_rule), + rule_name_field(repo_rule), + "package rule {group_name:?} matcher field in .github/renovate.json5 drifted from default.json" ); assert_eq!( - package_names(repo_rule), - sorted_package_names(repo_rule), - "package rule {group_name:?} matchPackageNames in .github/renovate.json5 must be sorted" + rule_names(default_rule), + rule_names(repo_rule), + "package rule {group_name:?} package matcher in .github/renovate.json5 drifted from default.json" + ); + assert_eq!( + rule_names(repo_rule), + sorted_rule_names(repo_rule), + "package rule {group_name:?} package matcher in .github/renovate.json5 must be sorted" ); } @@ -574,8 +579,51 @@ fn package_names(rule: &serde_json::Value) -> Vec<&str> { .collect() } -fn sorted_package_names(rule: &serde_json::Value) -> Vec<&str> { - let mut names = package_names(rule); +fn dep_names(rule: &serde_json::Value) -> Vec<&str> { + rule["matchDepNames"] + .as_array() + .expect("package rule must declare matchDepNames") + .iter() + .map(|value| { + value + .as_str() + .expect("package rule matchDepNames entries must be strings") + }) + .collect() +} + +fn sorted_dep_names(rule: &serde_json::Value) -> Vec<&str> { + let mut names = dep_names(rule); + names.sort_unstable(); + names +} + +fn rule_name_field(rule: &serde_json::Value) -> &'static str { + match ( + rule.get("matchDepNames").is_some(), + rule.get("matchPackageNames").is_some(), + ) { + (true, false) => "matchDepNames", + (false, true) => "matchPackageNames", + (true, true) => { + panic!("package rule must not declare both matchDepNames and matchPackageNames") + } + (false, false) => { + panic!("package rule must declare matchDepNames or matchPackageNames") + } + } +} + +fn rule_names(rule: &serde_json::Value) -> Vec<&str> { + match rule_name_field(rule) { + "matchDepNames" => dep_names(rule), + "matchPackageNames" => package_names(rule), + _ => unreachable!("unexpected rule_name_field result"), + } +} + +fn sorted_rule_names(rule: &serde_json::Value) -> Vec<&str> { + let mut names = rule_names(rule); names.sort_unstable(); names } @@ -780,7 +828,8 @@ fn replace_section(haystack: &str, start_marker: &str, end_marker: &str, body: & fn generate_summary_table(registry: &[Check]) -> String { // Summary table: Name | Description | Fix — sorted alphabetically. - // Name column links to the matching detail section in docs/linters.md. + // Name column links to the matching detail section in docs/linters.md, + // except for checks with a dedicated guide page. let headers = ["Name", "Description", "Fix"]; let mut sorted: Vec<&Check> = registry.iter().collect(); sorted.sort_by_key(|c| c.name); @@ -829,9 +878,7 @@ fn generate_linter_details(registry: &[Check]) -> String { } fn summary_row(check: &Check) -> [String; 3] { - // docs/linters.md uses `## ``` — GitHub strips backticks and - // lowercases to produce the anchor ``. - let name = format!("[`{0}`](docs/linters.md#{0})", check.name); + let name = format!("[`{}`]({})", check.name, detail_link(check)); let desc = if check.desc.is_empty() { "—".to_string() } else { @@ -841,6 +888,12 @@ fn summary_row(check: &Check) -> [String; 3] { [name, desc, fix] } +fn detail_link(check: &Check) -> String { + // docs/linters.md uses `## ``` — GitHub strips backticks and + // lowercases to produce the anchor ``. + format!("docs/linters.md#{}", check.name) +} + fn detail_table(check: &Check) -> String { let rows = detail_rows(check); 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 index b46b3391..ab5dd26e 100644 --- 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 @@ -1,8 +1,11 @@ { - "package.json": { - "npm": [ - "express", - "lodash" - ] + "meta": {}, + "files": { + "package.json": { + "npm": [ + "express", + "lodash" + ] + } } } diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate-tracked-deps.json index b46b3391..80b05878 100644 --- a/tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate-tracked-deps.json +++ b/tests/cases/renovate-deps/fast-only-irrelevant/files/.github/renovate-tracked-deps.json @@ -1,8 +1,14 @@ { - "package.json": { - "npm": [ - "express", - "lodash" - ] + "meta": { + "express": {}, + "lodash": {} + }, + "files": { + "package.json": { + "npm": [ + "express", + "lodash" + ] + } } } diff --git a/tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate-tracked-deps.json index b46b3391..ab5dd26e 100644 --- a/tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate-tracked-deps.json +++ b/tests/cases/renovate-deps/fast-only-relevant/files/.github/renovate-tracked-deps.json @@ -1,8 +1,11 @@ { - "package.json": { - "npm": [ - "express", - "lodash" - ] + "meta": {}, + "files": { + "package.json": { + "npm": [ + "express", + "lodash" + ] + } } } diff --git a/tests/cases/renovate-deps/fix-create/test.toml b/tests/cases/renovate-deps/fix-create/test.toml index dd181064..caeedb67 100644 --- a/tests/cases/renovate-deps/fix-create/test.toml +++ b/tests/cases/renovate-deps/fix-create/test.toml @@ -11,16 +11,19 @@ GITHUB_TOKEN = "token" [expected.files] ".github/renovate-tracked-deps.json" = """ { - "mise.toml": { - "mise": [ - "npm:renovate" - ] - }, - "package.json": { - "npm": [ - "express", - "lodash" - ] + "meta": {}, + "files": { + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, + "package.json": { + "npm": [ + "express", + "lodash" + ] + } } } """ 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 index 167d26b2..ce981f86 100644 --- 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 @@ -1,7 +1,10 @@ { - "package.json": { - "npm": [ - "old-dep" - ] + "meta": {}, + "files": { + "package.json": { + "npm": [ + "old-dep" + ] + } } } diff --git a/tests/cases/renovate-deps/fix-update/test.toml b/tests/cases/renovate-deps/fix-update/test.toml index dd181064..caeedb67 100644 --- a/tests/cases/renovate-deps/fix-update/test.toml +++ b/tests/cases/renovate-deps/fix-update/test.toml @@ -11,16 +11,19 @@ GITHUB_TOKEN = "token" [expected.files] ".github/renovate-tracked-deps.json" = """ { - "mise.toml": { - "mise": [ - "npm:renovate" - ] - }, - "package.json": { - "npm": [ - "express", - "lodash" - ] + "meta": {}, + "files": { + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, + "package.json": { + "npm": [ + "express", + "lodash" + ] + } } } """ diff --git a/tests/cases/renovate-deps/inconsistent-rule-coverage/files/.github/renovate-tracked-deps.json b/tests/cases/renovate-deps/inconsistent-rule-coverage/files/.github/renovate-tracked-deps.json new file mode 100644 index 00000000..53cefe09 --- /dev/null +++ b/tests/cases/renovate-deps/inconsistent-rule-coverage/files/.github/renovate-tracked-deps.json @@ -0,0 +1,24 @@ +{ + "meta": { + "actionlint": { + "packageName": "rhysd/actionlint", + "datasource": "github-releases" + }, + "rhysd/actionlint": { + "packageName": "rhysd/actionlint", + "datasource": "github-releases" + } + }, + "files": { + "README.md": { + "regex": [ + "rhysd/actionlint" + ] + }, + "mise.toml": { + "mise": [ + "actionlint" + ] + } + } +} diff --git a/tests/cases/renovate-deps/inconsistent-rule-coverage/files/.github/renovate.json5 b/tests/cases/renovate-deps/inconsistent-rule-coverage/files/.github/renovate.json5 new file mode 100644 index 00000000..e8514ac6 --- /dev/null +++ b/tests/cases/renovate-deps/inconsistent-rule-coverage/files/.github/renovate.json5 @@ -0,0 +1,8 @@ +{ + packageRules: [ + { + groupName: "linters", + matchDepNames: ["actionlint"] + } + ] +} diff --git a/tests/cases/renovate-deps/inconsistent-rule-coverage/files/README.md b/tests/cases/renovate-deps/inconsistent-rule-coverage/files/README.md new file mode 100644 index 00000000..83c831f0 --- /dev/null +++ b/tests/cases/renovate-deps/inconsistent-rule-coverage/files/README.md @@ -0,0 +1 @@ +# test diff --git a/tests/cases/renovate-deps/inconsistent-rule-coverage/files/mise.toml b/tests/cases/renovate-deps/inconsistent-rule-coverage/files/mise.toml new file mode 100644 index 00000000..1faa014b --- /dev/null +++ b/tests/cases/renovate-deps/inconsistent-rule-coverage/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"npm:renovate" = "43.136.3" diff --git a/tests/cases/renovate-deps/inconsistent-rule-coverage/test.toml b/tests/cases/renovate-deps/inconsistent-rule-coverage/test.toml new file mode 100644 index 00000000..4493501b --- /dev/null +++ b/tests/cases/renovate-deps/inconsistent-rule-coverage/test.toml @@ -0,0 +1,18 @@ +[expected] +args = "run --full renovate-deps" +exit = 1 +stderr_contains = [ + "flint: renovate-deps: package rule group \"linters\" matches package rhysd/actionlint inconsistently", + "matched [actionlint], unmatched [rhysd/actionlint]", +] + +[env] +GITHUB_TOKEN = "token" + +# Fake Renovate output is enough here because the failure is in Flint's +# post-extraction rule-coverage validation, not in invoking the real binary. +[fake_bins] +renovate = ''' +#!/bin/sh +printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"mise":[{"packageFile":"mise.toml","deps":[{"depName":"actionlint","packageName":"rhysd/actionlint","datasource":"github-releases"}]}],"regex":[{"packageFile":"README.md","deps":[{"depName":"rhysd/actionlint","packageName":"rhysd/actionlint","datasource":"github-releases"}]}]}}' +''' 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 index 167d26b2..ce981f86 100644 --- 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 @@ -1,7 +1,10 @@ { - "package.json": { - "npm": [ - "old-dep" - ] + "meta": {}, + "files": { + "package.json": { + "npm": [ + "old-dep" + ] + } } } diff --git a/tests/cases/renovate-deps/out-of-date/test.toml b/tests/cases/renovate-deps/out-of-date/test.toml index 28f8ba42..072420d0 100644 --- a/tests/cases/renovate-deps/out-of-date/test.toml +++ b/tests/cases/renovate-deps/out-of-date/test.toml @@ -1,31 +1,12 @@ [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. -''' +stderr_contains = [ + "--- .github/renovate-tracked-deps.json", + "\"mise.toml\": {", + "\"npm:renovate\"", + "ERROR: renovate-tracked-deps.json is out of date.", +] [env] GITHUB_TOKEN = "token" @@ -33,10 +14,13 @@ GITHUB_TOKEN = "token" [expected.files] ".github/renovate-tracked-deps.json" = """ { - "package.json": { - "npm": [ - "old-dep" - ] + "meta": {}, + "files": { + "package.json": { + "npm": [ + "old-dep" + ] + } } } """ 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 index 41f0847b..d87f4100 100644 --- 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 @@ -1,13 +1,16 @@ { - "mise.toml": { - "mise": [ - "npm:renovate" - ] - }, - "package.json": { - "npm": [ - "express", - "lodash" - ] + "meta": {}, + "files": { + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, + "package.json": { + "npm": [ + "express", + "lodash" + ] + } } } 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 index 41f0847b..d87f4100 100644 --- 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 @@ -1,13 +1,16 @@ { - "mise.toml": { - "mise": [ - "npm:renovate" - ] - }, - "package.json": { - "npm": [ - "express", - "lodash" - ] + "meta": {}, + "files": { + "mise.toml": { + "mise": [ + "npm:renovate" + ] + }, + "package.json": { + "npm": [ + "express", + "lodash" + ] + } } }