diff --git a/README.md b/README.md index 32d88aba..c6fd447b 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,10 @@ run = "flint run --fix" run: mise run lint ``` -The GitHub environment variables let flint remap base-branch links to the PR -branch when link checking. `fetch-depth: 0` is required for merge-base -detection. +`fetch-depth: 0` is required for merge-base detection. `GITHUB_TOKEN` is needed +by some checks that query GitHub, but not every check. If `lychee` link checks +are enabled, see [lychee](docs/linters.md#lychee) for PR remap environment +requirements. --- diff --git a/docs/cli.md b/docs/cli.md index 2c2419db..0cb23c3c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -57,7 +57,7 @@ A check runs against all matching files when: - another supported baseline config for the check changed, such as `.editorconfig` for `editorconfig-checker` - `flint.toml` changed under `[settings]` -- `flint.toml` changed the check-specific config for a special check, such as +- `flint.toml` changed the check-specific config for a native check, such as `[checks.links]` or `[checks.renovate-deps]` `--full` is still the explicit whole-repo mode. The automatic baseline behavior diff --git a/docs/linters.md b/docs/linters.md index 00da01ef..455cedbd 100644 --- a/docs/linters.md +++ b/docs/linters.md @@ -106,7 +106,7 @@ still choose the config directory via `FLINT_CONFIG_DIR` where supported. | Description | Keep Flint setup current and mise.toml lint tooling canonical | | Fix | yes | | Binary | (built-in) | -| Scope | [special](#scope-special) | +| Scope | [native](#scope-native) | | Patterns | `mise.toml` | Checks the repo's Flint-managed setup state and `mise.toml` layout. @@ -182,7 +182,7 @@ With `--fix`, rewrites Flint-managed config in place and advances | Description | Check source files have the required license header | | Fix | no | | Binary | (built-in) | -| Scope | [special](#scope-special) | +| Scope | [native](#scope-native) | ## `lychee` @@ -191,7 +191,7 @@ With `--fix`, rewrites Flint-managed config in place and advances | Description | Check for broken links | | Fix | no | | Binary | `lychee` | -| Scope | [special](#scope-special) | +| Scope | [native](#scope-native) | | Config | via `[checks.links]` in flint.toml | Orchestrates [lychee](https://lychee.cli.rs/) for link checking. Requires `lychee` in `[tools]`. @@ -201,6 +201,13 @@ Default behavior: checks all links in changed files. When in all files — useful when broken internal links from unchanged files also matter. +In CI, `lychee` requires `GITHUB_TOKEN` so GitHub link checks can authenticate. +On GitHub Actions PR runs in changed-file mode, link remaps also require +`GITHUB_REPOSITORY`, `GITHUB_BASE_REF`, `GITHUB_HEAD_REF`, and `PR_HEAD_REPO`. +GitHub Actions provides the first three; set `PR_HEAD_REPO` from +`github.event.pull_request.head.repo.full_name`. `--full` does not require +the PR remap metadata. + Configure via `flint.toml`: ```toml @@ -216,7 +223,7 @@ check_all_local = true | Description | Verify Renovate dependency snapshot is up to date | | Fix | yes | | Binary | `renovate` | -| Scope | [special](#scope-special) | +| Scope | [native](#scope-native) | | Patterns | `renovate.json renovate.json5 .github/renovate.json .github/renovate.json5 .renovaterc .renovaterc.json .renovaterc.json5` | | Run policy | adaptive — runs in `--fast-only` only when relevant | @@ -224,6 +231,14 @@ Verifies `.github/renovate-tracked-deps.json` is up to date by running Renovate locally and comparing its output against the committed snapshot. Requires `renovate` in `[tools]`. +In CI, `renovate-deps` requires `GITHUB_COM_TOKEN` or `GITHUB_TOKEN` +so Renovate can authenticate GitHub requests. If `GITHUB_COM_TOKEN` is +unset, flint forwards `GITHUB_TOKEN` to Renovate as `GITHUB_COM_TOKEN`. + +When `flint init` writes a new `flint.toml`, it includes this section if +`renovate-deps` is selected. During v1 setup migration it also carries +legacy `RENOVATE_TRACKED_DEPS_EXCLUDE` values into `exclude_managers`. + With `--fix`, automatically regenerates and commits the snapshot. Configure via `flint.toml`: @@ -347,7 +362,7 @@ Invoked once with no file args; for checks with patterns set (e.g. whole project when it does run. `golangci-lint` is the exception — it uses `--new-from-rev` to scope analysis to changed code even within the project run. -### Scope: special +### Scope: native Implemented in-process rather than via a command template. These checks may run without file arguments or use custom orchestration logic. diff --git a/src/config.rs b/src/config.rs index 85011553..0d641904 100644 --- a/src/config.rs +++ b/src/config.rs @@ -110,7 +110,7 @@ pub fn load(config_dir: &Path) -> Result { // FLINT_BASE_BRANCH, FLINT_EXCLUDE → settings.* // FLINT_LYCHEE_CONFIG, FLINT_LYCHEE_* → checks.lychee.* // FLINT_RENOVATE_DEPS_EXCLUDE_MANAGERS → checks.renovate_deps.* - // New Special checks added to the registry get env support automatically. + // New native checks added to the registry get env support automatically. .merge(Env::prefixed("FLINT_").map(move |k| { let k = k.as_str(); for (prefix, namespace) in §ions { diff --git a/src/files.rs b/src/files.rs index 0696029c..8e837759 100644 --- a/src/files.rs +++ b/src/files.rs @@ -9,7 +9,7 @@ use crate::linters::renovate_deps::COMMITTED_PATHS; /// Files managed by flint itself — always excluded from generic linter checks. const BUILTIN_EXCLUDES: &[&str] = COMMITTED_PATHS; -#[derive(Clone)] +#[derive(Debug, Clone)] pub struct FileList { pub files: Vec, /// Changed paths from git before user excludes are applied. @@ -187,6 +187,57 @@ fn filter_names( .collect() } +pub fn match_files<'a>( + files: &'a [PathBuf], + patterns: &[&str], + exclude_patterns: &[&str], + project_root: &Path, +) -> Vec<&'a PathBuf> { + files + .iter() + .filter(|p| { + let rel = p.strip_prefix(project_root).unwrap_or(p); + let rel_str = rel.to_string_lossy(); + let file_name = p + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + let included = patterns.iter().any(|pat| { + if *pat == "*" { + return true; + } + glob_match(pat, file_name.as_ref()) || glob_match(pat, rel_str.as_ref()) + }); + let excluded = exclude_patterns.iter().any(|pat| { + glob_match(pat, file_name.as_ref()) || glob_match(pat, rel_str.as_ref()) + }); + included && !excluded + }) + .collect() +} + +fn glob_match(pattern: &str, name: &str) -> bool { + // Simple glob: splits on `*` and checks that each segment appears in order. + // Handles `*.ext`, `prefix*`, `dir/*.yml`, etc. + let parts: Vec<&str> = pattern.splitn(2, '*').collect(); + match parts.as_slice() { + [only] => name == *only || name.ends_with(&format!("/{only}")), + [prefix, suffix] => { + let n = name; + // The prefix must match the start of the name (or the part after the last slash). + let anchor_start = prefix.is_empty() || n.starts_with(prefix) || { + // Allow matching the basename portion for patterns like `*.sh`. + n.contains('/') && { + let after_slash = n.rfind('/').map(|i| &n[i + 1..]).unwrap_or(n); + prefix.is_empty() || after_slash.starts_with(prefix) + } + }; + anchor_start && n.ends_with(suffix) + } + _ => false, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/init/config_files.rs b/src/init/config_files.rs index 89e2eb4f..f9d80598 100644 --- a/src/init/config_files.rs +++ b/src/init/config_files.rs @@ -1,5 +1,4 @@ use anyhow::{Context, Result}; -use serde::Deserialize; use std::io; use std::path::Path; use std::process::Command; @@ -8,16 +7,10 @@ use crate::registry::EditorconfigDirectiveStyle; /// Writes a skeleton `flint.toml` in `config_dir`. Creates the directory if needed. /// Returns `true` if the file was written, `false` if it already existed. -/// -/// `exclude_managers`: when `Some`, populates `exclude_managers` in `[checks.renovate-deps]` -/// with the given list (migrated from `RENOVATE_TRACKED_DEPS_EXCLUDE`). When `None` and -/// `has_renovate` is true, writes a commented-out placeholder instead. pub(super) fn generate_flint_toml( config_dir: &Path, base_branch: &str, setup_migration_version: u32, - has_renovate: bool, - exclude_managers: Option<&[String]>, ) -> Result { let toml_path = config_dir.join("flint.toml"); if toml_path.exists() { @@ -32,20 +25,6 @@ pub(super) fn generate_flint_toml( "setup_migration_version = {setup_migration_version}\n" )); content.push_str("# exclude = [\"CHANGELOG\\\\.md\"]\n"); - if has_renovate { - content.push_str("\n[checks.renovate-deps]\n"); - match exclude_managers { - Some(managers) if !managers.is_empty() => { - let list = managers - .iter() - .map(|m| format!("\"{m}\"")) - .collect::>() - .join(", "); - content.push_str(&format!("exclude_managers = [{list}]\n")); - } - _ => content.push_str("# exclude_managers = []\n"), - } - } std::fs::write(&toml_path, &content)?; println!(" wrote {}", toml_path.display()); Ok(true) @@ -58,7 +37,7 @@ pub(crate) fn write_setup_migration_version( ) -> Result { let toml_path = config_dir.join("flint.toml"); if !toml_path.exists() { - return generate_flint_toml(config_dir, base_branch, version, false, None); + return generate_flint_toml(config_dir, base_branch, version); } let content = std::fs::read_to_string(&toml_path) @@ -87,253 +66,6 @@ pub(crate) fn write_setup_migration_version( Ok(true) } -/// Generates `.rumdl.toml` in the flint config dir when rumdl is being set up. -/// Returns `true` if the file was written (or an older markdownlint variant was replaced). -pub(super) fn generate_rumdl_config( - project_root: &Path, - config_dir: &Path, - line_length: u16, -) -> Result { - const LEGACY_CONFIG_NAMES: &[&str] = &[ - ".markdownlint.json", - ".markdownlint.jsonc", - ".markdownlint.yaml", - ".markdownlint.yml", - ".markdownlint-cli2.jsonc", - ".markdownlint-cli2.yaml", - ".markdownlint-cli2.yml", - ".markdownlint-cli2.cjs", - ".markdownlint-cli2.mjs", - ]; - let target = config_dir.join(".rumdl.toml"); - if target.exists() { - return Ok(false); - } - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent)?; - } - let content = converted_legacy_markdownlint_config(project_root)? - .unwrap_or_else(|| default_rumdl_config(line_length)); - for name in LEGACY_CONFIG_NAMES { - let legacy = project_root.join(name); - if legacy.exists() { - std::fs::remove_file(&legacy)?; - println!(" removed {} (replaced by .rumdl.toml)", legacy.display()); - } - } - std::fs::write(&target, content)?; - println!(" wrote {}", target.display()); - Ok(true) -} - -fn default_rumdl_config(line_length: u16) -> String { - format!( - "[MD013]\n\ - enabled = true\n\ - line-length = {line_length}\n\ - code-blocks = false\n\ - tables = false\n\ - \n\ - [MD060]\n\ - enabled = true\n\ - style = \"aligned\"\n", - ) -} - -fn converted_legacy_markdownlint_config(project_root: &Path) -> Result> { - const LEGACY_CONFIG_NAMES: &[&str] = &[ - ".markdownlint.json", - ".markdownlint.jsonc", - ".markdownlint.yaml", - ".markdownlint.yml", - ".markdownlint-cli2.jsonc", - ".markdownlint-cli2.yaml", - ".markdownlint-cli2.yml", - ".markdownlint-cli2.cjs", - ".markdownlint-cli2.mjs", - ]; - - for name in LEGACY_CONFIG_NAMES { - let path = project_root.join(name); - if !path.exists() { - continue; - } - if let Some(config) = parse_legacy_markdownlint_config(&path)? { - return Ok(Some(render_rumdl_config_from_legacy(&config))); - } - } - - Ok(None) -} - -#[derive(Debug, Default, Deserialize)] -struct LegacyMarkdownlintConfig { - #[serde(rename = "line-length", alias = "MD013")] - line_length: Option>, - #[serde(rename = "ul-style", alias = "MD004")] - ul_style: Option>, - #[serde(rename = "no-duplicate-heading", alias = "MD024")] - no_duplicate_heading: Option>, - #[serde(rename = "ol-prefix", alias = "MD029")] - ol_prefix: Option>, - #[serde(rename = "no-inline-html", alias = "MD033")] - no_inline_html: Option>, - #[serde(rename = "fenced-code-language", alias = "MD040")] - fenced_code_language: Option>, - #[serde(rename = "no-trailing-punctuation", alias = "MD026")] - no_trailing_punctuation: Option>, - #[serde(rename = "MD041")] - md041: Option>, - #[serde(rename = "MD059")] - md059: Option>, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum LegacyRuleSetting { - Bool(bool), - Config(T), -} - -#[derive(Debug, Default, Deserialize)] -struct EmptyRule {} - -#[derive(Debug, Default, Deserialize)] -struct LegacyLineLengthRule { - #[serde(rename = "line_length")] - line_length: Option, - #[serde(rename = "code_blocks")] - code_blocks: Option, - tables: Option, -} - -#[derive(Debug, Default, Deserialize)] -struct LegacyNoDuplicateHeadingRule { - #[serde(rename = "siblings_only")] - siblings_only: Option, -} - -#[derive(Debug, Default, Deserialize)] -struct LegacyOlPrefixRule { - style: Option, -} - -#[derive(Debug, Default, Deserialize)] -struct LegacyNoTrailingPunctuationRule { - punctuation: Option, -} - -fn parse_legacy_markdownlint_config(path: &Path) -> Result> { - let content = std::fs::read_to_string(path) - .with_context(|| format!("failed to read {}", path.display()))?; - let ext = path - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or_default(); - - let parsed = match ext { - "json" | "jsonc" => json5::from_str::(&content).ok(), - "yaml" | "yml" => serde_yaml::from_str::(&content).ok(), - _ => None, - }; - Ok(parsed) -} - -fn render_rumdl_config_from_legacy(config: &LegacyMarkdownlintConfig) -> String { - let mut out = String::new(); - let mut global_disable = vec![]; - - append_global_disable(&mut global_disable, "line-length", &config.line_length); - append_global_disable(&mut global_disable, "ul-style", &config.ul_style); - append_global_disable( - &mut global_disable, - "no-inline-html", - &config.no_inline_html, - ); - append_global_disable( - &mut global_disable, - "fenced-code-language", - &config.fenced_code_language, - ); - append_global_disable(&mut global_disable, "MD041", &config.md041); - append_global_disable(&mut global_disable, "MD059", &config.md059); - - if !global_disable.is_empty() { - out.push_str("[global]\n"); - out.push_str("disable = ["); - out.push_str( - &global_disable - .iter() - .map(|rule| format!("\"{rule}\"")) - .collect::>() - .join(", "), - ); - out.push_str("]\n"); - } - - if let Some(LegacyRuleSetting::Config(rule)) = &config.line_length { - if !out.is_empty() { - out.push('\n'); - } - out.push_str("[MD013]\n"); - if let Some(line_length) = rule.line_length { - out.push_str("enabled = true\n"); - out.push_str(&format!("line-length = {line_length}\n")); - } - if let Some(code_blocks) = rule.code_blocks { - out.push_str(&format!("code-blocks = {code_blocks}\n")); - } - if let Some(tables) = rule.tables { - out.push_str(&format!("tables = {tables}\n")); - } - } - - if let Some(LegacyRuleSetting::Config(rule)) = &config.no_duplicate_heading - && rule.siblings_only.is_some() - { - if !out.is_empty() { - out.push('\n'); - } - out.push_str("[no-duplicate-heading]\n"); - out.push_str(&format!( - "siblings-only = {}\n", - rule.siblings_only.unwrap_or(false) - )); - } - - if let Some(LegacyRuleSetting::Config(rule)) = &config.no_trailing_punctuation - && let Some(punctuation) = &rule.punctuation - { - if !out.is_empty() { - out.push('\n'); - } - out.push_str("[no-trailing-punctuation]\n"); - out.push_str(&format!("punctuation = \"{punctuation}\"\n")); - } - - if let Some(LegacyRuleSetting::Config(rule)) = &config.ol_prefix - && let Some(style) = &rule.style - { - if !out.is_empty() { - out.push('\n'); - } - out.push_str("[ol-prefix]\n"); - out.push_str(&format!("style = \"{style}\"\n")); - } - - out -} - -fn append_global_disable( - global_disable: &mut Vec<&'static str>, - rule_name: &'static str, - setting: &Option>, -) { - if matches!(setting, Some(LegacyRuleSetting::Bool(false))) { - global_disable.push(rule_name); - } -} - /// Removes stale v1/super-linter-era files that flint v2 no longer uses. /// Returns the list of removed paths relative to `project_root`. pub(super) fn remove_legacy_lint_files( @@ -762,105 +494,3 @@ fn editorconfig_section_header(patterns: &[&str]) -> String { format!("[{{{}}}]", patterns.join(",")) } } - -/// Generates `.yamllint.yml` in the flint config dir when ryl is being set up. -pub(super) fn generate_yamllint_config(config_dir: &Path, line_length: u16) -> Result { - let target = config_dir.join(".yamllint.yml"); - if target.exists() { - return Ok(false); - } - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent)?; - } - let content = [ - "extends: relaxed", - "", - "rules:", - " document-start: disable", - " line-length:", - &format!(" max: {line_length}"), - " indentation: enable", - "", - ] - .join("\n"); - std::fs::write(&target, content)?; - println!(" wrote {}", target.display()); - Ok(true) -} - -/// Generates `.taplo.toml` in the flint config dir when taplo is being set up. -pub(super) fn generate_taplo_config(config_dir: &Path, line_length: u16) -> Result { - const SUPPORTED_CONFIG_NAMES: &[&str] = &[".taplo.toml"]; - const LEGACY_CONFIG_NAMES: &[&str] = &["taplo.toml"]; - if SUPPORTED_CONFIG_NAMES - .iter() - .map(|name| config_dir.join(name)) - .any(|path| path.exists()) - || LEGACY_CONFIG_NAMES - .iter() - .map(|name| config_dir.join(name)) - .any(|path| path.exists()) - { - return Ok(false); - } - let target = config_dir.join(".taplo.toml"); - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent)?; - } - let content = [ - "[formatting]".to_string(), - format!("column_width = {line_length}"), - "indent_string = \" \"".to_string(), - ] - .join("\n") - + "\n"; - std::fs::write(&target, content)?; - println!(" wrote {}", target.display()); - Ok(true) -} - -/// Generates `rustfmt.toml` in the flint config dir when cargo-fmt is being set up. -pub(super) fn generate_rustfmt_config(config_dir: &Path, line_length: u16) -> Result { - let target = config_dir.join("rustfmt.toml"); - if target.exists() { - return Ok(false); - } - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent)?; - } - let content = format!("max_width = {line_length}\n"); - std::fs::write(&target, content)?; - println!(" wrote {}", target.display()); - Ok(true) -} - -/// Generates root `biome.jsonc` when biome is being set up and no -/// existing supported config is present. -/// -/// Flint writes explicit space indentation to avoid Biome's default tab -/// formatting surprising consumers during rollout. -pub(super) fn generate_biome_config(project_root: &Path) -> Result { - let target = project_root.join("biome.jsonc"); - if target.exists() { - return Ok(false); - } - let legacy = project_root.join("biome.json"); - if legacy.exists() { - std::fs::rename(&legacy, &target)?; - println!(" moved {} -> {}", legacy.display(), target.display()); - return Ok(true); - } - let content = [ - "{", - " \"formatter\": {", - " \"indentStyle\": \"space\",", - " \"indentWidth\": 2", - " }", - "}", - "", - ] - .join("\n"); - std::fs::write(&target, content)?; - println!(" wrote {}", target.display()); - Ok(true) -} diff --git a/src/init/generation.rs b/src/init/generation.rs index dc2bcc14..9c16ac02 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -12,7 +12,6 @@ pub(crate) use super::mise_tools::{ needs_node_for_npm, normalize_tools_section, replace_obsolete_keys, tools_section_needs_normalization, }; -pub(super) use super::renovate::{flint_preset, patch_renovate_extends}; pub(super) use super::v1::remove_v1_tasks; /// Returns true if any currently-selected check has `Category::Slow`. diff --git a/src/init/mod.rs b/src/init/mod.rs index 38a68e6f..8abf7791 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -4,14 +4,13 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; -use crate::registry::{Category, Check, builtin}; +use crate::registry::{Category, Check, InitHookContext, WorkflowSetup, builtin}; mod config_files; mod detection; pub(crate) mod generation; mod migrations; mod mise_tools; -mod renovate; mod scaffold; mod ui; mod v1; @@ -19,17 +18,15 @@ mod v1; pub(crate) use config_files::write_setup_migration_version; use config_files::{ - disable_editorconfig_line_length_for_patterns, generate_biome_config, generate_editorconfig, - generate_flint_toml, generate_rumdl_config, generate_rustfmt_config, generate_taplo_config, - generate_yamllint_config, + disable_editorconfig_line_length_for_patterns, generate_editorconfig, generate_flint_toml, }; use detection::{ build_linter_groups, detect_obsolete_keys, detect_present_patterns, parse_tool_keys, }; use generation::{ - apply_changes, detect_base_branch, ensure_flint_self_pin, ensure_node_for_npm, flint_preset, - get_existing_config_dir, has_slow_selected, normalize_tools_section, patch_renovate_extends, - prompt_config_dir, remove_tool_keys, remove_v1_tasks, + apply_changes, detect_base_branch, ensure_flint_self_pin, ensure_node_for_npm, + get_existing_config_dir, has_slow_selected, normalize_tools_section, prompt_config_dir, + remove_tool_keys, remove_v1_tasks, }; use migrations::{ apply_repo_migrations, selected_editorconfig_cleanup_sections, @@ -79,6 +76,64 @@ fn selected_checks<'a>(groups: &'a [LinterGroup<'a>]) -> Vec<&'a Check> { .collect() } +struct CheckTypeInitHookContext<'a> { + project_root: &'a Path, + config_dir: &'a Path, + line_length: u16, + flint_toml_generated: bool, + renovate_exclude_managers: Option<&'a [String]>, +} + +impl InitHookContext for CheckTypeInitHookContext<'_> { + fn project_root(&self) -> &Path { + self.project_root + } + + fn config_dir(&self) -> &Path { + self.config_dir + } + + fn line_length(&self) -> u16 { + self.line_length + } + + fn flint_toml_generated(&self) -> bool { + self.flint_toml_generated + } + + fn renovate_exclude_managers(&self) -> Option<&[String]> { + self.renovate_exclude_managers + } +} + +fn apply_check_type_init_hooks( + checks: &[&Check], + project_root: &Path, + config_dir: &Path, + line_length: u16, + flint_toml_generated: bool, + renovate_exclude_managers: Option<&[String]>, +) -> Result { + let context = CheckTypeInitHookContext { + project_root, + config_dir, + line_length, + flint_toml_generated, + renovate_exclude_managers, + }; + let mut changed = false; + let mut initialized_check_types = HashSet::new(); + for check in checks { + if let Some(check_type) = check.check_type + && let Some(hook) = check_type.init_hook() + && initialized_check_types.insert(check_type.name()) + { + changed |= hook(&context)?; + } + } + Ok(changed) +} + /// Desired tools for a profile: maps each mise tool key to its optional components string. #[cfg(test)] type DesiredTools = HashMap>; @@ -283,42 +338,7 @@ Add and stage your source files before running init so the detection is accurate } let has_slow = has_slow_selected(&groups); - let has_renovate = groups.iter().any(|g| { - g.checks - .iter() - .zip(&g.check_selected) - .any(|(c, &sel)| sel && c.name == "renovate-deps") - }); - let has_rumdl = groups.iter().any(|g| { - g.checks - .iter() - .zip(&g.check_selected) - .any(|(c, &sel)| sel && c.name == "rumdl") - }); - let has_yaml_lint = groups.iter().any(|g| { - g.checks - .iter() - .zip(&g.check_selected) - .any(|(c, &sel)| sel && c.name == "ryl") - }); - let has_taplo = groups.iter().any(|g| { - g.checks - .iter() - .zip(&g.check_selected) - .any(|(c, &sel)| sel && c.name == "taplo") - }); - let has_biome = groups.iter().any(|g| { - g.checks - .iter() - .zip(&g.check_selected) - .any(|(c, &sel)| sel && (c.name == "biome" || c.name == "biome-format")) - }); - let has_cargo_fmt = groups.iter().any(|g| { - g.checks - .iter() - .zip(&g.check_selected) - .any(|(c, &sel)| sel && c.name == "cargo-fmt") - }); + let selected_checks = selected_checks(&groups); // Prompt for the flint config dir (skipped if already set in mise.toml or --yes). let existing_config_dir = get_existing_config_dir(¤t_content); @@ -357,18 +377,20 @@ Add and stage your source files before running init so the detection is accurate &config_dir_path, &base_branch, crate::setup::LATEST_SUPPORTED_SETUP_VERSION, - has_renovate, + )?; + let needs_rust_components = selected_checks + .iter() + .any(|check| check.workflow_setup == Some(WorkflowSetup::RustComponents)); + let workflow_generated = + generate_lint_workflow(project_root, &base_branch, needs_rust_components)?; + let check_type_init_changed = apply_check_type_init_hooks( + &selected_checks, + project_root, + &config_dir_path, + line_length, + toml_generated, v1.renovate_exclude_managers.as_deref(), )?; - let has_rust = final_add.iter().any(|(k, _)| k == "rust") - || (current_tool_keys.contains("rust") && !final_remove.iter().any(|k| k == "rust")); - let workflow_generated = generate_lint_workflow(project_root, &base_branch, has_rust)?; - let rumdl_generated = if has_rumdl { - generate_rumdl_config(project_root, &config_dir_path, line_length)? - } else { - false - }; - let selected_checks = selected_checks(&groups); let editorconfig_line_length_sections = selected_editorconfig_line_length_sections(&selected_checks); let editorconfig_cleanup_sections = selected_editorconfig_cleanup_sections(&selected_checks); @@ -389,39 +411,6 @@ Add and stage your source files before running init so the detection is accurate editorconfig_line_length_disabled.join(", ") ); } - let yamllint_generated = if has_yaml_lint { - generate_yamllint_config(&config_dir_path, line_length)? - } else { - false - }; - let taplo_generated = if has_taplo { - generate_taplo_config(&config_dir_path, line_length)? - } else { - false - }; - let rustfmt_generated = if has_cargo_fmt { - generate_rustfmt_config(&config_dir_path, line_length)? - } else { - false - }; - let biome_generated = if has_biome { - generate_biome_config(project_root)? - } else { - false - }; - - let renovate_patched = find_renovate_config(project_root) - .map(|path| { - let result = patch_renovate_extends(&path); - if let Ok(true) = result { - let rel = path.strip_prefix(project_root).unwrap_or(&path); - println!(" patched {} — added {}", rel.display(), flint_preset()); - } - result - }) - .transpose()? - .unwrap_or(false); - if !tools_changed && migration_summary.is_noop() && !flint_pinned @@ -431,14 +420,9 @@ Add and stage your source files before running init so the detection is accurate && !tools_normalized && !toml_generated && !workflow_generated - && !rumdl_generated + && !check_type_init_changed && !editorconfig_generated && editorconfig_line_length_disabled.is_empty() - && !yamllint_generated - && !taplo_generated - && !rustfmt_generated - && !biome_generated - && !renovate_patched { println!("No changes to apply."); return Ok(()); @@ -450,13 +434,6 @@ Add and stage your source files before running init so the detection is accurate Ok(()) } -fn find_renovate_config(project_root: &Path) -> Option { - crate::linters::renovate_deps::RENOVATE_CONFIG_PATTERNS - .iter() - .map(|p| project_root.join(p)) - .find(|p| p.exists()) -} - /// Returns the canonical mise.toml tool key to write when installing this check /// via `flint init`, or `None` if no mise entry is needed (built-in or /// unconditionally active checks). diff --git a/src/init/renovate.rs b/src/init/renovate.rs deleted file mode 100644 index 0db2dc49..00000000 --- a/src/init/renovate.rs +++ /dev/null @@ -1,190 +0,0 @@ -use anyhow::{Context, Result}; -use std::path::Path; - -/// Returns the renovate preset entry to inject, e.g. `github>grafana/flint#v0.9.2`. -/// Pre-release suffixes are stripped so dev builds produce a valid tag reference. -pub(super) fn flint_preset() -> String { - let ver = env!("CARGO_PKG_VERSION"); - let ver = ver.split('-').next().unwrap_or(ver); - format!("github>grafana/flint#v{ver}") -} - -/// Adds the flint renovate preset to the `extends` array in a renovate config file. -/// Works for both JSON and JSON5. If an unpinned or differently-pinned flint entry -/// already exists, it is replaced in-place rather than duplicated. -/// Returns `true` if the file was changed. -pub(super) fn patch_renovate_extends(path: &Path) -> Result { - let entry = flint_preset(); - let content = std::fs::read_to_string(path)?; - - if content.contains(&entry) { - return Ok(false); - } - - // If an existing flint entry (any pin) is present, replace it in-place. - const FLINT_ENTRY_PREFIX: &str = "\"github>grafana/flint"; - let new_content = if let Some(pos) = content.find(FLINT_ENTRY_PREFIX) { - let after_open = pos + 1; // skip leading " - let close = content[after_open..] - .find('"') - .context("unclosed quote in existing flint preset entry")?; - let end = after_open + close + 1; // position after closing " - format!("{}\"{}\"{}", &content[..pos], entry, &content[end..]) - } else { - add_to_extends(&content, &entry) - .with_context(|| format!("failed to patch extends in {}", path.display()))? - }; - - std::fs::write(path, new_content)?; - Ok(true) -} - -/// Text-based insertion of `entry` into the `extends` array. -/// Works for both JSON (`"extends": [`) and JSON5 (`extends: [`). -fn add_to_extends(content: &str, entry: &str) -> Result { - let re = regex::Regex::new(r#"(?:"extends"|extends)\s*:\s*\["#).unwrap(); - - if let Some(m) = re.find(content) { - let bracket_pos = m.end() - 1; // index of '[' - let inside_start = bracket_pos + 1; - - let close_offset = content[inside_start..] - .find(']') - .context("extends array has no closing ]")?; - let close_pos = inside_start + close_offset; - let inside = &content[inside_start..close_pos]; - - if inside.contains('\n') { - // Multiline: detect indent from first non-empty line, insert at top - let indent = inside - .lines() - .find(|l| !l.trim().is_empty()) - .map(|l| " ".repeat(l.len() - l.trim_start().len())) - .unwrap_or_else(|| " ".to_string()); - Ok(format!( - "{}\n{}\"{}\"{}{}", - &content[..inside_start], - indent, - entry, - ",", - &content[inside_start..] - )) - } else { - // Single-line (empty or not): prepend entry - let sep = if inside.trim().is_empty() { "" } else { ", " }; - Ok(format!( - "{}\"{}\"{}{}", - &content[..inside_start], - entry, - sep, - &content[inside_start..] - )) - } - } else { - // No extends key — add after the opening { - let open = content - .find('{') - .context("no opening { in renovate config")?; - let (before, after) = content.split_at(open + 1); - Ok(format!( - "{}\n \"extends\": [\"{}\"],{}", - before, entry, after - )) - } -} - -#[cfg(test)] -mod tests { - use super::{add_to_extends, patch_renovate_extends}; - - fn write_tmp(content: &str) -> tempfile::NamedTempFile { - let f = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(f.path(), content).unwrap(); - f - } - - #[test] - fn replaces_unpinned_flint_entry_in_place() { - let input = r#"{ extends: ["config:recommended", "github>grafana/flint"] }"#; - let tmp = write_tmp(input); - let changed = patch_renovate_extends(tmp.path()).unwrap(); - assert!(changed); - let result = std::fs::read_to_string(tmp.path()).unwrap(); - assert!( - result.contains("github>grafana/flint#v"), - "pinned entry written: {result}" - ); - assert_eq!( - result.matches("grafana/flint").count(), - 1, - "no duplicate: {result}" - ); - assert!( - !result.contains("\"github>grafana/flint\""), - "unpinned removed: {result}" - ); - } - - #[test] - fn replaces_differently_pinned_flint_entry() { - let input = r#"{ extends: ["config:recommended", "github>grafana/flint#v0.5.0"] }"#; - let tmp = write_tmp(input); - let changed = patch_renovate_extends(tmp.path()).unwrap(); - assert!(changed); - let result = std::fs::read_to_string(tmp.path()).unwrap(); - assert!(!result.contains("v0.5.0"), "old pin removed: {result}"); - assert_eq!( - result.matches("grafana/flint").count(), - 1, - "no duplicate: {result}" - ); - } - - #[test] - fn no_op_when_already_pinned_to_current_version() { - let entry = super::flint_preset(); - let input = format!(r#"{{ extends: ["config:recommended", "{entry}"] }}"#); - let tmp = write_tmp(&input); - let changed = patch_renovate_extends(tmp.path()).unwrap(); - assert!(!changed); - } - - #[test] - fn adds_to_single_line_extends() { - let input = r#"{ "extends": ["config:recommended"], "other": 1 }"#; - let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); - assert!(result.contains(r#"["github>grafana/flint#v0.9.2", "config:recommended"]"#)); - } - - #[test] - fn adds_to_json5_unquoted_key() { - let input = "{\n extends: [\"config:recommended\"],\n}\n"; - let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); - assert!(result.contains(r#""github>grafana/flint#v0.9.2", "config:recommended""#)); - } - - #[test] - fn adds_to_multiline_extends() { - let input = "{\n extends: [\n \"config:recommended\",\n \"other\"\n ]\n}\n"; - let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); - assert!(result.contains("\"github>grafana/flint#v0.9.2\",")); - let flint_pos = result.find("grafana/flint").unwrap(); - let existing_pos = result.find("config:recommended").unwrap(); - assert!(flint_pos < existing_pos); - } - - #[test] - fn adds_extends_when_absent() { - let input = "{\n \"branchPrefix\": \"renovate/\"\n}\n"; - let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); - assert!(result.contains("\"extends\"")); - assert!(result.contains("github>grafana/flint#v0.9.2")); - } - - #[test] - fn adds_to_empty_extends_array() { - let input = r#"{ "extends": [] }"#; - let result = add_to_extends(input, "github>grafana/flint#v0.9.2").unwrap(); - assert!(result.contains(r#"["github>grafana/flint#v0.9.2"]"#)); - } -} diff --git a/src/init/scaffold.rs b/src/init/scaffold.rs index bbab6a07..c9394804 100644 --- a/src/init/scaffold.rs +++ b/src/init/scaffold.rs @@ -7,7 +7,7 @@ use std::path::Path; pub(super) fn generate_lint_workflow( project_root: &Path, base_branch: &str, - has_rust: bool, + needs_rust_components: bool, ) -> Result { let workflows_dir = project_root.join(".github/workflows"); let workflow_path = workflows_dir.join("lint.yml"); @@ -15,12 +15,12 @@ pub(super) fn generate_lint_workflow( return Ok(false); } std::fs::create_dir_all(&workflows_dir)?; - let push_comment = if has_rust { + let push_comment = if needs_rust_components { " # warms the Rust cache so PR branches get a cache hit" } else { "" }; - let rust_steps = if has_rust { + let rust_steps = if needs_rust_components { "\n - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1\n\n - name: Install Rust lint components\n run: rustup component add clippy rustfmt\n" } else { "" diff --git a/src/init/tests.rs b/src/init/tests.rs index b1a13bee..e0fc5ce1 100644 --- a/src/init/tests.rs +++ b/src/init/tests.rs @@ -1,10 +1,22 @@ use super::*; +use crate::registry::CheckTypeDef; use config_files::generate_flint_toml; use detection::entry_components_differ; use generation::{ apply_changes, get_existing_config_dir, has_slow_selected, normalize_tools_section, }; use scaffold::{apply_env_and_tasks, generate_lint_workflow}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static CHECK_TYPE_INIT_CALLS: AtomicUsize = AtomicUsize::new(0); + +fn counting_check_type_init(_: &dyn InitHookContext) -> anyhow::Result { + CHECK_TYPE_INIT_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(true) +} + +static COUNTING_CHECK_TYPE: CheckTypeDef = + CheckTypeDef::with_init_hook("shared-hook", counting_check_type_init); #[test] fn detect_obsolete_keys_finds_known_stale_key() { @@ -44,6 +56,26 @@ fn all_registry_checks_have_install_key_or_none() { } } +#[test] +fn apply_check_type_init_hooks_runs_shared_hook_once() { + CHECK_TYPE_INIT_CALLS.store(0, Ordering::SeqCst); + let first = Check::file("first", "first {FILE}", &["*"]).check_type(&COUNTING_CHECK_TYPE); + let second = Check::file("second", "second {FILE}", &["*"]).check_type(&COUNTING_CHECK_TYPE); + let tmp = tempfile::TempDir::new().unwrap(); + let changed = apply_check_type_init_hooks( + &[&first, &second], + tmp.path(), + tmp.path(), + DEFAULT_LINE_LENGTH, + false, + None, + ) + .unwrap(); + + assert!(changed); + assert_eq!(CHECK_TYPE_INIT_CALLS.load(Ordering::SeqCst), 1); +} + #[test] fn entry_components_differ_string_value() { let content = "[tools]\nrust = \"1.80.0\"\n"; @@ -338,10 +370,10 @@ fn get_existing_config_dir_absent() { #[test] fn generate_rumdl_config_writes_file() { - use config_files::generate_rumdl_config; + use crate::linters::rumdl::generate_config; let tmp = tempfile::TempDir::new().unwrap(); let config_dir = tmp.path().join(".github/config"); - let written = generate_rumdl_config(tmp.path(), &config_dir, DEFAULT_LINE_LENGTH).unwrap(); + let written = generate_config(tmp.path(), &config_dir, DEFAULT_LINE_LENGTH).unwrap(); assert!(written); let content = std::fs::read_to_string(config_dir.join(".rumdl.toml")).unwrap(); assert!(content.contains("line-length = 120")); @@ -353,12 +385,12 @@ fn generate_rumdl_config_writes_file() { #[test] fn generate_rumdl_config_skips_when_target_exists() { - use config_files::generate_rumdl_config; + use crate::linters::rumdl::generate_config; let tmp = tempfile::TempDir::new().unwrap(); let config_dir = tmp.path().join(".github/config"); std::fs::create_dir_all(&config_dir).unwrap(); std::fs::write(config_dir.join(".rumdl.toml"), "existing").unwrap(); - let written = generate_rumdl_config(tmp.path(), &config_dir, DEFAULT_LINE_LENGTH).unwrap(); + let written = generate_config(tmp.path(), &config_dir, DEFAULT_LINE_LENGTH).unwrap(); assert!(!written); let content = std::fs::read_to_string(config_dir.join(".rumdl.toml")).unwrap(); assert_eq!(content, "existing"); @@ -366,11 +398,11 @@ fn generate_rumdl_config_skips_when_target_exists() { #[test] fn generate_rumdl_config_replaces_legacy_json() { - use config_files::generate_rumdl_config; + use crate::linters::rumdl::generate_config; let tmp = tempfile::TempDir::new().unwrap(); let config_dir = tmp.path().join(".github/config"); std::fs::write(tmp.path().join(".markdownlint.json"), r#"{"MD013":false}"#).unwrap(); - let written = generate_rumdl_config(tmp.path(), &config_dir, DEFAULT_LINE_LENGTH).unwrap(); + let written = generate_config(tmp.path(), &config_dir, DEFAULT_LINE_LENGTH).unwrap(); assert!(written); assert!(!tmp.path().join(".markdownlint.json").exists()); let content = std::fs::read_to_string(config_dir.join(".rumdl.toml")).unwrap(); @@ -380,7 +412,7 @@ fn generate_rumdl_config_replaces_legacy_json() { #[test] fn generate_rumdl_config_converts_legacy_yaml() { - use config_files::generate_rumdl_config; + use crate::linters::rumdl::generate_config; let tmp = tempfile::TempDir::new().unwrap(); let config_dir = tmp.path().join(".github/config"); std::fs::write( @@ -401,7 +433,7 @@ MD041: false "#, ) .unwrap(); - let written = generate_rumdl_config(tmp.path(), &config_dir, DEFAULT_LINE_LENGTH).unwrap(); + let written = generate_config(tmp.path(), &config_dir, DEFAULT_LINE_LENGTH).unwrap(); assert!(written); assert!(!tmp.path().join(".markdownlint.yaml").exists()); let content = std::fs::read_to_string(config_dir.join(".rumdl.toml")).unwrap(); @@ -582,10 +614,10 @@ fn disable_editorconfig_line_length_for_patterns_is_idempotent() { #[test] fn generate_yamllint_config_writes_file() { - use config_files::generate_yamllint_config; + use crate::linters::yamllint::generate_config; let tmp = tempfile::TempDir::new().unwrap(); let config_dir = tmp.path().join(".github/config"); - let written = generate_yamllint_config(&config_dir, DEFAULT_LINE_LENGTH).unwrap(); + let written = generate_config(&config_dir, DEFAULT_LINE_LENGTH).unwrap(); assert!(written); let content = std::fs::read_to_string(config_dir.join(".yamllint.yml")).unwrap(); assert_eq!( @@ -596,10 +628,10 @@ fn generate_yamllint_config_writes_file() { #[test] fn generate_taplo_config_writes_file() { - use config_files::generate_taplo_config; + use crate::linters::taplo::generate_config; let tmp = tempfile::TempDir::new().unwrap(); let config_dir = tmp.path().join(".github/config"); - let written = generate_taplo_config(&config_dir, DEFAULT_LINE_LENGTH).unwrap(); + let written = generate_config(&config_dir, DEFAULT_LINE_LENGTH).unwrap(); assert!(written); let content = std::fs::read_to_string(config_dir.join(".taplo.toml")).unwrap(); assert!(content.contains("[formatting]")); @@ -609,12 +641,12 @@ fn generate_taplo_config_writes_file() { #[test] fn generate_taplo_config_skips_existing_supported_file() { - use config_files::generate_taplo_config; + use crate::linters::taplo::generate_config; let tmp = tempfile::TempDir::new().unwrap(); let config_dir = tmp.path().join(".github/config"); std::fs::create_dir_all(&config_dir).unwrap(); std::fs::write(config_dir.join(".taplo.toml"), "existing").unwrap(); - let written = generate_taplo_config(&config_dir, DEFAULT_LINE_LENGTH).unwrap(); + let written = generate_config(&config_dir, DEFAULT_LINE_LENGTH).unwrap(); assert!(!written); let content = std::fs::read_to_string(config_dir.join(".taplo.toml")).unwrap(); assert_eq!(content, "existing"); @@ -622,22 +654,22 @@ fn generate_taplo_config_skips_existing_supported_file() { #[test] fn generate_taplo_config_skips_existing_legacy_name() { - use config_files::generate_taplo_config; + use crate::linters::taplo::generate_config; let tmp = tempfile::TempDir::new().unwrap(); let config_dir = tmp.path().join(".github/config"); std::fs::create_dir_all(&config_dir).unwrap(); std::fs::write(config_dir.join("taplo.toml"), "existing").unwrap(); - let written = generate_taplo_config(&config_dir, DEFAULT_LINE_LENGTH).unwrap(); + let written = generate_config(&config_dir, DEFAULT_LINE_LENGTH).unwrap(); assert!(!written); assert!(!config_dir.join(".taplo.toml").exists()); } #[test] fn generate_rustfmt_config_writes_file() { - use config_files::generate_rustfmt_config; + use crate::linters::rustfmt::generate_config; let tmp = tempfile::TempDir::new().unwrap(); let config_dir = tmp.path().join(".github/config"); - let written = generate_rustfmt_config(&config_dir, DEFAULT_LINE_LENGTH).unwrap(); + let written = generate_config(&config_dir, DEFAULT_LINE_LENGTH).unwrap(); assert!(written); let content = std::fs::read_to_string(config_dir.join("rustfmt.toml")).unwrap(); assert_eq!(content, "max_width = 120\n"); @@ -645,12 +677,12 @@ fn generate_rustfmt_config_writes_file() { #[test] fn generate_rustfmt_config_skips_existing_file() { - use config_files::generate_rustfmt_config; + use crate::linters::rustfmt::generate_config; let tmp = tempfile::TempDir::new().unwrap(); let config_dir = tmp.path().join(".github/config"); std::fs::create_dir_all(&config_dir).unwrap(); std::fs::write(config_dir.join("rustfmt.toml"), "existing").unwrap(); - let written = generate_rustfmt_config(&config_dir, DEFAULT_LINE_LENGTH).unwrap(); + let written = generate_config(&config_dir, DEFAULT_LINE_LENGTH).unwrap(); assert!(!written); let content = std::fs::read_to_string(config_dir.join("rustfmt.toml")).unwrap(); assert_eq!(content, "existing"); @@ -658,9 +690,9 @@ fn generate_rustfmt_config_skips_existing_file() { #[test] fn generate_biome_config_writes_file() { - use config_files::generate_biome_config; + use crate::linters::biome::generate_config; let tmp = tempfile::TempDir::new().unwrap(); - let written = generate_biome_config(tmp.path()).unwrap(); + let written = generate_config(tmp.path()).unwrap(); assert!(written); let content = std::fs::read_to_string(tmp.path().join("biome.jsonc")).unwrap(); assert!(content.contains("\"indentStyle\": \"space\"")); @@ -669,10 +701,10 @@ fn generate_biome_config_writes_file() { #[test] fn generate_biome_config_skips_existing_jsonc() { - use config_files::generate_biome_config; + use crate::linters::biome::generate_config; let tmp = tempfile::TempDir::new().unwrap(); std::fs::write(tmp.path().join("biome.jsonc"), "existing").unwrap(); - let written = generate_biome_config(tmp.path()).unwrap(); + let written = generate_config(tmp.path()).unwrap(); assert!(!written); let content = std::fs::read_to_string(tmp.path().join("biome.jsonc")).unwrap(); assert_eq!(content, "existing"); @@ -680,10 +712,10 @@ fn generate_biome_config_skips_existing_jsonc() { #[test] fn generate_biome_config_migrates_legacy_supported_json_name() { - use config_files::generate_biome_config; + use crate::linters::biome::generate_config; let tmp = tempfile::TempDir::new().unwrap(); std::fs::write(tmp.path().join("biome.json"), "existing").unwrap(); - let written = generate_biome_config(tmp.path()).unwrap(); + let written = generate_config(tmp.path()).unwrap(); assert!(written); assert!(!tmp.path().join("biome.json").exists()); let content = std::fs::read_to_string(tmp.path().join("biome.jsonc")).unwrap(); @@ -694,14 +726,8 @@ fn generate_biome_config_migrates_legacy_supported_json_name() { fn generate_flint_toml_writes_skeleton() { let tmp = tempfile::TempDir::new().unwrap(); let dir = tmp.path().join("config"); - let written = generate_flint_toml( - &dir, - "main", - crate::setup::V2_BASELINE_SETUP_VERSION, - false, - None, - ) - .unwrap(); + let written = + generate_flint_toml(&dir, "main", crate::setup::V2_BASELINE_SETUP_VERSION).unwrap(); assert!(written); let content = std::fs::read_to_string(dir.join("flint.toml")).unwrap(); assert!(content.contains("[settings]")); @@ -716,8 +742,6 @@ fn generate_flint_toml_non_main_branch() { tmp.path(), "master", crate::setup::V2_BASELINE_SETUP_VERSION, - false, - None, ) .unwrap(); assert!(written); @@ -725,55 +749,12 @@ fn generate_flint_toml_non_main_branch() { assert!(content.contains("base_branch = \"master\"")); } -#[test] -fn generate_flint_toml_with_renovate_placeholder() { - let tmp = tempfile::TempDir::new().unwrap(); - generate_flint_toml( - tmp.path(), - "main", - crate::setup::V2_BASELINE_SETUP_VERSION, - true, - None, - ) - .unwrap(); - let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); - assert!(content.contains("[checks.renovate-deps]")); - assert!(content.contains("# exclude_managers =")); -} - -#[test] -fn generate_flint_toml_with_renovate_managers() { - let tmp = tempfile::TempDir::new().unwrap(); - let managers = vec!["github-actions".to_string(), "cargo".to_string()]; - generate_flint_toml( - tmp.path(), - "main", - crate::setup::V2_BASELINE_SETUP_VERSION, - true, - Some(&managers), - ) - .unwrap(); - let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); - assert!(content.contains("[checks.renovate-deps]")); - assert!( - content.contains("exclude_managers = [\"github-actions\", \"cargo\"]"), - "managers written uncommented: {content}" - ); - assert!(!content.contains("# exclude_managers")); -} - #[test] fn generate_flint_toml_skips_existing() { let tmp = tempfile::TempDir::new().unwrap(); std::fs::write(tmp.path().join("flint.toml"), "existing content").unwrap(); - let written = generate_flint_toml( - tmp.path(), - "main", - crate::setup::V2_BASELINE_SETUP_VERSION, - false, - None, - ) - .unwrap(); + let written = + generate_flint_toml(tmp.path(), "main", crate::setup::V2_BASELINE_SETUP_VERSION).unwrap(); assert!(!written); let content = std::fs::read_to_string(tmp.path().join("flint.toml")).unwrap(); assert_eq!(content, "existing content"); @@ -798,6 +779,7 @@ fn generate_lint_workflow_writes_file() { )); assert!(!content.contains("GITHUB_HEAD_SHA")); assert!(content.contains("github.token")); + assert!(content.contains("pull_request.head.repo.full_name")); assert!(!content.contains("rust-cache")); assert!(!content.contains("rustup component")); } diff --git a/src/linters/biome.rs b/src/linters/biome.rs new file mode 100644 index 00000000..e259f6d4 --- /dev/null +++ b/src/linters/biome.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use std::path::Path; + +use crate::registry::{CheckTypeDef, InitHookContext}; + +pub(crate) static CHECK_TYPE: CheckTypeDef = CheckTypeDef::with_init_hook("biome", init); + +pub(crate) fn init(ctx: &dyn InitHookContext) -> Result { + generate_config(ctx.project_root()) +} + +pub(crate) fn generate_config(project_root: &Path) -> Result { + let target = project_root.join("biome.jsonc"); + if target.exists() { + return Ok(false); + } + let legacy = project_root.join("biome.json"); + if legacy.exists() { + std::fs::rename(&legacy, &target)?; + println!(" moved {} -> {}", legacy.display(), target.display()); + return Ok(true); + } + let content = [ + "{", + " \"formatter\": {", + " \"indentStyle\": \"space\",", + " \"indentWidth\": 2", + " }", + "}", + "", + ] + .join("\n"); + std::fs::write(&target, content)?; + println!(" wrote {}", target.display()); + Ok(true) +} diff --git a/src/linters/env.rs b/src/linters/env.rs new file mode 100644 index 00000000..99f16354 --- /dev/null +++ b/src/linters/env.rs @@ -0,0 +1,88 @@ +pub(crate) const CI_ENV_VARS: &[&str] = + &["CI", "GITHUB_ACTIONS", "GITHUB_ACTION", "GITHUB_WORKFLOW"]; +pub(crate) const GITHUB_COM_TOKEN_ENV: &str = "GITHUB_COM_TOKEN"; +pub(crate) const GITHUB_TOKEN_ENV: &str = "GITHUB_TOKEN"; + +pub(crate) fn is_ci_from(env: F) -> bool +where + F: Fn(&str) -> Option, +{ + CI_ENV_VARS.iter().any(|name| env_truthy(&env, name)) +} + +pub(crate) fn env_non_empty(env: &F, name: &str) -> bool +where + F: Fn(&str) -> Option, +{ + env(name) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) +} + +pub(crate) fn github_token_available(env: &F) -> bool +where + F: Fn(&str) -> Option, +{ + env_non_empty(env, GITHUB_TOKEN_ENV) +} + +pub(crate) fn renovate_github_token_available(env: &F) -> bool +where + F: Fn(&str) -> Option, +{ + env_non_empty(env, GITHUB_COM_TOKEN_ENV) || github_token_available(env) +} + +pub(crate) fn token_warning(check_name: &str, token_names: &str) -> String { + format!( + "flint: warning: {token_names} is not set; {check_name} GitHub requests may be rate limited" + ) +} + +fn env_truthy(env: &F, name: &str) -> bool +where + F: Fn(&str) -> Option, +{ + env(name) + .map(|value| { + let value = value.trim(); + !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false") + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn detects_truthy_ci_env() { + let vars = HashMap::from([("CI".to_string(), "true".to_string())]); + + assert!(is_ci_from(|name| vars.get(name).cloned())); + } + + #[test] + fn ignores_false_ci_env() { + let vars = HashMap::from([("CI".to_string(), "false".to_string())]); + + assert!(!is_ci_from(|name| vars.get(name).cloned())); + } + + #[test] + fn detects_non_empty_github_token() { + let vars = HashMap::from([("GITHUB_TOKEN".to_string(), "token".to_string())]); + + assert!(github_token_available(&|name| vars.get(name).cloned())); + } + + #[test] + fn detects_renovate_github_com_token() { + let vars = HashMap::from([("GITHUB_COM_TOKEN".to_string(), "token".to_string())]); + + assert!(renovate_github_token_available(&|name| vars + .get(name) + .cloned())); + } +} diff --git a/src/linters/flint_setup.rs b/src/linters/flint_setup.rs index 737eec01..c7f92893 100644 --- a/src/linters/flint_setup.rs +++ b/src/linters/flint_setup.rs @@ -1,8 +1,60 @@ use std::path::Path; +use std::path::PathBuf; use crate::init::generation::{normalize_tools_section, tools_section_needs_normalization}; use crate::init::write_setup_migration_version; use crate::linters::LinterOutput; +use crate::registry::{ + CheckTypeDef, NativeCheckDef, NativePrepareContext, NativeRunContext, NativeRunFuture, + PreparedNativeCheck, +}; + +pub(crate) static CHECK_TYPE: CheckTypeDef = CheckTypeDef::native( + "flint-setup", + NativeCheckDef::new(prepare).with_fix().setup(), +); + +#[derive(Debug)] +struct PreparedFlintSetup { + name: String, + config_dir: PathBuf, + setup_migration_version: u32, + tracked_files: Vec, +} + +fn prepare(ctx: NativePrepareContext<'_>) -> Option> { + Some(Box::new(PreparedFlintSetup { + name: ctx.name.to_string(), + config_dir: ctx.config_dir.to_path_buf(), + setup_migration_version: ctx.cfg.settings.setup_migration_version, + tracked_files: vec![ + ctx.project_root.join("mise.toml"), + ctx.config_dir.join("flint.toml"), + ], + })) +} + +impl PreparedNativeCheck for PreparedFlintSetup { + 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::flint_setup::run( + ctx.fix, + &ctx.project_root, + &self.config_dir, + self.setup_migration_version, + ) + .await + }) + } +} pub async fn run( fix: bool, diff --git a/src/linters/license_header.rs b/src/linters/license_header.rs index 98e03fb0..e5bfef43 100644 --- a/src/linters/license_header.rs +++ b/src/linters/license_header.rs @@ -2,7 +2,60 @@ use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use crate::config::LicenseHeaderConfig; +use crate::files::match_files; use crate::linters::LinterOutput; +use crate::registry::{ + CheckTypeDef, NativeCheckDef, NativePrepareContext, NativeRunContext, NativeRunFuture, + PreparedNativeCheck, StatusContext, +}; + +pub(crate) static CHECK_TYPE: CheckTypeDef = + CheckTypeDef::native("license-header", NativeCheckDef::new(prepare)); + +#[derive(Debug)] +struct PreparedLicenseHeader { + name: String, + cfg: LicenseHeaderConfig, + files: Vec, +} + +fn prepare(ctx: NativePrepareContext<'_>) -> Option> { + if ctx.cfg.checks.license_header.text.is_empty() { + return None; + } + let patterns: Vec<&str> = ctx + .cfg + .checks + .license_header + .patterns + .iter() + .map(String::as_str) + .collect(); + let files: Vec = match_files(&ctx.file_list.files, &patterns, &[], ctx.project_root) + .into_iter() + .cloned() + .collect(); + if files.is_empty() { + return None; + } + Some(Box::new(PreparedLicenseHeader { + name: ctx.name.to_string(), + cfg: ctx.cfg.checks.license_header.clone(), + files, + })) +} + +impl PreparedNativeCheck for PreparedLicenseHeader { + fn name(&self) -> &str { + &self.name + } + + fn run(self: Box, ctx: NativeRunContext) -> NativeRunFuture { + Box::pin(async move { + crate::linters::license_header::run(&self.cfg, &ctx.project_root, &self.files).await + }) + } +} /// Checks that each file contains `cfg.text` within the first `cfg.lines_to_check` lines. /// Files are pre-filtered by pattern in the runner; this function checks all of them. @@ -38,6 +91,15 @@ pub async fn run( } } +pub(crate) fn status(ctx: &dyn StatusContext) -> Option<&'static str> { + ctx.config() + .checks + .license_header + .text + .is_empty() + .then_some("not configured") +} + /// Returns `true` if `text` appears anywhere within the first `lines_to_check` lines of `path`. /// `text` may be multi-line; the file head is joined with `\n` before the substring search. fn check_file(path: &Path, text: &str, lines_to_check: usize) -> std::io::Result { diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs index 72295bf6..c707edf5 100644 --- a/src/linters/lychee.rs +++ b/src/linters/lychee.rs @@ -1,10 +1,71 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Stdio; use tokio::process::Command; use crate::config::{Config, LycheeConfig, Settings}; use crate::files::FileList; use crate::linters::LinterOutput; +use crate::linters::env; +use crate::registry::{ + CheckTypeDef, NativeCheckDef, NativePrepareContext, NativeRunContext, NativeRunFuture, + PreparedNativeCheck, +}; + +const GITHUB_BASE_REF_ENV: &str = "GITHUB_BASE_REF"; +const GITHUB_EVENT_NAME_ENV: &str = "GITHUB_EVENT_NAME"; +const GITHUB_HEAD_REF_ENV: &str = "GITHUB_HEAD_REF"; +const GITHUB_REPOSITORY_ENV: &str = "GITHUB_REPOSITORY"; +const PR_HEAD_REPO_ENV: &str = "PR_HEAD_REPO"; +const PR_LINK_REMAP_ENV_VARS: &[&str] = &[ + GITHUB_REPOSITORY_ENV, + GITHUB_BASE_REF_ENV, + GITHUB_HEAD_REF_ENV, + PR_HEAD_REPO_ENV, +]; + +pub(crate) static CHECK_TYPE: CheckTypeDef = CheckTypeDef::native( + "lychee", + NativeCheckDef::with_bin("lychee", prepare) + .with_config_display("via `[checks.links]` in flint.toml"), +); + +#[derive(Debug)] +struct PreparedLychee { + name: String, + cfg: LycheeConfig, + settings: Settings, + file_list: FileList, + config_dir: PathBuf, +} + +fn prepare(ctx: NativePrepareContext<'_>) -> Option> { + Some(Box::new(PreparedLychee { + name: ctx.name.to_string(), + cfg: ctx.cfg.checks.lychee.clone(), + settings: ctx.cfg.settings.clone(), + file_list: ctx.file_list.clone(), + config_dir: ctx.config_dir.to_path_buf(), + })) +} + +impl PreparedNativeCheck for PreparedLychee { + fn name(&self) -> &str { + &self.name + } + + fn run(self: Box, ctx: NativeRunContext) -> NativeRunFuture { + Box::pin(async move { + crate::linters::lychee::run( + &self.cfg, + &self.settings, + &self.file_list, + &ctx.project_root, + &self.config_dir, + ) + .await + }) + } +} pub async fn run( cfg: &LycheeConfig, @@ -13,6 +74,18 @@ pub async fn run( project_root: &Path, config_dir: &Path, ) -> LinterOutput { + match validate_runtime_env(file_list) { + Ok(Some(warning)) => eprintln!("{warning}"), + Ok(None) => {} + Err(stderr) => { + return LinterOutput { + ok: false, + stdout: Vec::new(), + stderr: stderr.into_bytes(), + }; + } + } + let lychee_cfg_raw = cfg.config.as_deref().unwrap_or("lychee.toml"); let lychee_cfg = if Path::new(lychee_cfg_raw).is_relative() { config_dir @@ -129,6 +202,93 @@ pub async fn run( } } +fn validate_runtime_env(file_list: &FileList) -> Result, String> { + validate_runtime_env_from(file_list.full, github_remaps_enabled(), |name| { + std::env::var(name).ok() + }) +} + +fn validate_runtime_env_from( + full: bool, + github_remaps_enabled: bool, + env: F, +) -> Result, String> +where + F: Fn(&str) -> Option, +{ + let is_ci = env::is_ci_from(&env); + let has_github_token = env::github_token_available(&env); + + let mut missing = Vec::new(); + if is_ci && !has_github_token { + missing.push(env::GITHUB_TOKEN_ENV); + } + + if is_ci && github_remaps_enabled && !full && is_github_pr_event(&env) { + missing.extend( + PR_LINK_REMAP_ENV_VARS + .iter() + .copied() + .filter(|name| !env::env_non_empty(&env, name)), + ); + } + + if !missing.is_empty() { + return Err(missing_ci_env_message(&missing)); + } + + if !is_ci && !has_github_token { + return Ok(Some(env::token_warning("lychee", env::GITHUB_TOKEN_ENV))); + } + + Ok(None) +} + +fn github_remaps_enabled() -> bool { + std::env::var("LYCHEE_SKIP_GITHUB_REMAPS").as_deref() != Ok("true") +} + +fn is_github_pr_event(env: &F) -> bool +where + F: Fn(&str) -> Option, +{ + env(GITHUB_EVENT_NAME_ENV) + .map(|event| matches!(event.as_str(), "pull_request" | "pull_request_target")) + .unwrap_or_else(|| { + env::env_non_empty(env, GITHUB_BASE_REF_ENV) + || env::env_non_empty(env, GITHUB_HEAD_REF_ENV) + || env::env_non_empty(env, PR_HEAD_REPO_ENV) + }) +} + +fn missing_ci_env_message(missing: &[&str]) -> String { + let noun = if missing.len() == 1 { + "variable" + } else { + "variables" + }; + let mut message = format!( + "flint: links: missing required CI environment {noun}: {}\n", + missing.join(", ") + ); + if missing.contains(&env::GITHUB_TOKEN_ENV) { + message.push_str(&format!( + " Set {token} so lychee can authenticate GitHub link checks in CI.\n", + token = env::GITHUB_TOKEN_ENV, + )); + } + if missing + .iter() + .any(|name| PR_LINK_REMAP_ENV_VARS.contains(name)) + { + message.push_str(&format!( + " PR link remaps in CI require GitHub PR metadata; set {pr_head_repo} to github.event.pull_request.head.repo.full_name.\n", + pr_head_repo = PR_HEAD_REPO_ENV, + )); + } + message +} + fn lychee_checkable_files(project_root: &Path, settings: &Settings) -> anyhow::Result> { let cfg = Config { settings: settings.clone(), @@ -415,6 +575,86 @@ fn is_link_checkable(path: &Path) -> bool { mod tests { use super::*; use crate::config::Settings; + use std::collections::HashMap; + + fn validate_env( + full: bool, + github_remaps_enabled: bool, + vars: &[(&str, &str)], + ) -> Result, String> { + let vars: HashMap = vars + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect(); + validate_runtime_env_from(full, github_remaps_enabled, |name| vars.get(name).cloned()) + } + + #[test] + fn ci_requires_github_token_even_in_full_mode() { + let err = validate_env(true, true, &[("CI", "true")]).unwrap_err(); + + assert!(err.contains("GITHUB_TOKEN"), "unexpected error:\n{err}"); + } + + #[test] + fn ci_pr_diff_requires_link_remap_env_vars() { + let err = validate_env( + false, + true, + &[ + ("CI", "true"), + ("GITHUB_EVENT_NAME", "pull_request"), + ("GITHUB_TOKEN", "token"), + ], + ) + .unwrap_err(); + + for name in [ + "GITHUB_REPOSITORY", + "GITHUB_BASE_REF", + "GITHUB_HEAD_REF", + "PR_HEAD_REPO", + ] { + assert!(err.contains(name), "missing {name} in error:\n{err}"); + } + } + + #[test] + fn ci_full_mode_does_not_require_link_remap_env_vars() { + let result = validate_env( + true, + true, + &[ + ("CI", "true"), + ("GITHUB_EVENT_NAME", "pull_request"), + ("GITHUB_TOKEN", "token"), + ], + ); + + assert!(result.is_ok(), "unexpected validation error: {result:?}"); + } + + #[test] + fn ci_pr_diff_allows_missing_link_remap_env_vars_when_remaps_are_disabled() { + let result = validate_env( + false, + false, + &[ + ("CI", "true"), + ("GITHUB_EVENT_NAME", "pull_request"), + ("GITHUB_TOKEN", "token"), + ], + ); + + assert!(result.is_ok(), "unexpected validation error: {result:?}"); + } + + #[test] + fn non_ci_missing_github_token_warns_without_failing() { + let warning = validate_env(false, true, &[]).unwrap().unwrap(); + + assert!(warning.contains("GITHUB_TOKEN")); + } #[test] fn parse_github_repo_https() { diff --git a/src/linters/mod.rs b/src/linters/mod.rs index bac82c69..4796fe15 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -1,7 +1,15 @@ +pub mod biome; +pub mod env; pub mod flint_setup; pub mod license_header; pub mod lychee; pub mod renovate_deps; +pub mod rumdl; +pub mod rustfmt; +pub mod taplo; +pub mod yamllint; + +pub use crate::registry::LinterOutput; /// Build a [`tokio::process::Command`] for the given argv. /// @@ -79,20 +87,3 @@ fn find_file_in_path(binary: &str) -> Option { candidate.is_file().then_some(candidate) }) } - -/// Output from a single linter run. -pub struct LinterOutput { - pub ok: bool, - pub stdout: Vec, - pub stderr: Vec, -} - -impl LinterOutput { - pub fn err(stderr: impl Into>) -> Self { - Self { - ok: false, - stdout: vec![], - stderr: stderr.into(), - } - } -} diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index 316f6ac2..ca95e58f 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -5,6 +6,11 @@ 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"]; @@ -18,18 +24,104 @@ pub(crate) const RENOVATE_CONFIG_PATTERNS: &[&str] = &[ ".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; @@ -81,6 +173,201 @@ pub(crate) fn is_relevant(file_list: &FileList, project_root: &Path) -> bool { committed.keys().any(|path| changed.contains(path)) } +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, @@ -162,14 +449,14 @@ async fn run_renovate(project_root: &Path, config_path: &Path) -> anyhow::Result config_path.to_string_lossy().into_owned(), )); // Renovate uses GITHUB_COM_TOKEN for github.com API calls; fall back to GITHUB_TOKEN. - let has_com_token = std::env::var("GITHUB_COM_TOKEN") + 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("GITHUB_TOKEN") + && let Ok(token) = std::env::var(env::GITHUB_TOKEN_ENV) && !token.is_empty() { - env.push(("GITHUB_COM_TOKEN".into(), token)); + env.push((env::GITHUB_COM_TOKEN_ENV.into(), token)); } let out = super::spawn_command( @@ -362,6 +649,180 @@ mod tests { .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( diff --git a/src/linters/rumdl.rs b/src/linters/rumdl.rs new file mode 100644 index 00000000..9a011ce9 --- /dev/null +++ b/src/linters/rumdl.rs @@ -0,0 +1,256 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::path::Path; + +use crate::registry::{CheckTypeDef, InitHookContext}; + +pub(crate) static CHECK_TYPE: CheckTypeDef = CheckTypeDef::with_init_hook("rumdl", init); + +pub(crate) fn init(ctx: &dyn InitHookContext) -> Result { + generate_config(ctx.project_root(), ctx.config_dir(), ctx.line_length()) +} + +pub(crate) fn generate_config( + project_root: &Path, + config_dir: &Path, + line_length: u16, +) -> Result { + const LEGACY_CONFIG_NAMES: &[&str] = &[ + ".markdownlint.json", + ".markdownlint.jsonc", + ".markdownlint.yaml", + ".markdownlint.yml", + ".markdownlint-cli2.jsonc", + ".markdownlint-cli2.yaml", + ".markdownlint-cli2.yml", + ".markdownlint-cli2.cjs", + ".markdownlint-cli2.mjs", + ]; + let target = config_dir.join(".rumdl.toml"); + if target.exists() { + return Ok(false); + } + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + let content = converted_legacy_markdownlint_config(project_root)? + .unwrap_or_else(|| default_config(line_length)); + for name in LEGACY_CONFIG_NAMES { + let legacy = project_root.join(name); + if legacy.exists() { + std::fs::remove_file(&legacy)?; + println!(" removed {} (replaced by .rumdl.toml)", legacy.display()); + } + } + std::fs::write(&target, content)?; + println!(" wrote {}", target.display()); + Ok(true) +} + +fn default_config(line_length: u16) -> String { + format!( + "[MD013]\n\ + enabled = true\n\ + line-length = {line_length}\n\ + code-blocks = false\n\ + tables = false\n\ + \n\ + [MD060]\n\ + enabled = true\n\ + style = \"aligned\"\n", + ) +} + +fn converted_legacy_markdownlint_config(project_root: &Path) -> Result> { + const LEGACY_CONFIG_NAMES: &[&str] = &[ + ".markdownlint.json", + ".markdownlint.jsonc", + ".markdownlint.yaml", + ".markdownlint.yml", + ".markdownlint-cli2.jsonc", + ".markdownlint-cli2.yaml", + ".markdownlint-cli2.yml", + ".markdownlint-cli2.cjs", + ".markdownlint-cli2.mjs", + ]; + + for name in LEGACY_CONFIG_NAMES { + let path = project_root.join(name); + if !path.exists() { + continue; + } + if let Some(config) = parse_legacy_markdownlint_config(&path)? { + return Ok(Some(render_rumdl_config_from_legacy(&config))); + } + } + + Ok(None) +} + +#[derive(Debug, Default, Deserialize)] +struct LegacyMarkdownlintConfig { + #[serde(rename = "line-length", alias = "MD013")] + line_length: Option>, + #[serde(rename = "ul-style", alias = "MD004")] + ul_style: Option>, + #[serde(rename = "no-duplicate-heading", alias = "MD024")] + no_duplicate_heading: Option>, + #[serde(rename = "ol-prefix", alias = "MD029")] + ol_prefix: Option>, + #[serde(rename = "no-inline-html", alias = "MD033")] + no_inline_html: Option>, + #[serde(rename = "fenced-code-language", alias = "MD040")] + fenced_code_language: Option>, + #[serde(rename = "no-trailing-punctuation", alias = "MD026")] + no_trailing_punctuation: Option>, + #[serde(rename = "MD041")] + md041: Option>, + #[serde(rename = "MD059")] + md059: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum LegacyRuleSetting { + Bool(bool), + Config(T), +} + +#[derive(Debug, Default, Deserialize)] +struct EmptyRule {} + +#[derive(Debug, Default, Deserialize)] +struct LegacyLineLengthRule { + #[serde(rename = "line_length")] + line_length: Option, + #[serde(rename = "code_blocks")] + code_blocks: Option, + tables: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct LegacyNoDuplicateHeadingRule { + #[serde(rename = "siblings_only")] + siblings_only: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct LegacyOlPrefixRule { + style: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct LegacyNoTrailingPunctuationRule { + punctuation: Option, +} + +fn parse_legacy_markdownlint_config(path: &Path) -> Result> { + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let ext = path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default(); + + let parsed = match ext { + "json" | "jsonc" => json5::from_str::(&content).ok(), + "yaml" | "yml" => serde_yaml::from_str::(&content).ok(), + _ => None, + }; + Ok(parsed) +} + +fn render_rumdl_config_from_legacy(config: &LegacyMarkdownlintConfig) -> String { + let mut out = String::new(); + let mut global_disable = vec![]; + + append_global_disable(&mut global_disable, "line-length", &config.line_length); + append_global_disable(&mut global_disable, "ul-style", &config.ul_style); + append_global_disable( + &mut global_disable, + "no-inline-html", + &config.no_inline_html, + ); + append_global_disable( + &mut global_disable, + "fenced-code-language", + &config.fenced_code_language, + ); + append_global_disable(&mut global_disable, "MD041", &config.md041); + append_global_disable(&mut global_disable, "MD059", &config.md059); + + if !global_disable.is_empty() { + out.push_str("[global]\n"); + out.push_str("disable = ["); + out.push_str( + &global_disable + .iter() + .map(|rule| format!("\"{rule}\"")) + .collect::>() + .join(", "), + ); + out.push_str("]\n"); + } + + if let Some(LegacyRuleSetting::Config(rule)) = &config.line_length { + if !out.is_empty() { + out.push('\n'); + } + out.push_str("[MD013]\n"); + if let Some(line_length) = rule.line_length { + out.push_str("enabled = true\n"); + out.push_str(&format!("line-length = {line_length}\n")); + } + if let Some(code_blocks) = rule.code_blocks { + out.push_str(&format!("code-blocks = {code_blocks}\n")); + } + if let Some(tables) = rule.tables { + out.push_str(&format!("tables = {tables}\n")); + } + } + + if let Some(LegacyRuleSetting::Config(rule)) = &config.no_duplicate_heading + && rule.siblings_only.is_some() + { + if !out.is_empty() { + out.push('\n'); + } + out.push_str("[no-duplicate-heading]\n"); + out.push_str(&format!( + "siblings-only = {}\n", + rule.siblings_only.unwrap_or(false) + )); + } + + if let Some(LegacyRuleSetting::Config(rule)) = &config.no_trailing_punctuation + && let Some(punctuation) = &rule.punctuation + { + if !out.is_empty() { + out.push('\n'); + } + out.push_str("[no-trailing-punctuation]\n"); + out.push_str(&format!("punctuation = \"{punctuation}\"\n")); + } + + if let Some(LegacyRuleSetting::Config(rule)) = &config.ol_prefix + && let Some(style) = &rule.style + { + if !out.is_empty() { + out.push('\n'); + } + out.push_str("[ol-prefix]\n"); + out.push_str(&format!("style = \"{style}\"\n")); + } + + out +} + +fn append_global_disable( + global_disable: &mut Vec<&'static str>, + rule_name: &'static str, + setting: &Option>, +) { + if matches!(setting, Some(LegacyRuleSetting::Bool(false))) { + global_disable.push(rule_name); + } +} diff --git a/src/linters/rustfmt.rs b/src/linters/rustfmt.rs new file mode 100644 index 00000000..6526ab34 --- /dev/null +++ b/src/linters/rustfmt.rs @@ -0,0 +1,24 @@ +use anyhow::Result; +use std::path::Path; + +use crate::registry::{CheckTypeDef, InitHookContext}; + +pub(crate) static CHECK_TYPE: CheckTypeDef = CheckTypeDef::with_init_hook("rustfmt", init); + +pub(crate) fn init(ctx: &dyn InitHookContext) -> Result { + generate_config(ctx.config_dir(), ctx.line_length()) +} + +pub(crate) fn generate_config(config_dir: &Path, line_length: u16) -> Result { + let target = config_dir.join("rustfmt.toml"); + if target.exists() { + return Ok(false); + } + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + let content = format!("max_width = {line_length}\n"); + std::fs::write(&target, content)?; + println!(" wrote {}", target.display()); + Ok(true) +} diff --git a/src/linters/taplo.rs b/src/linters/taplo.rs new file mode 100644 index 00000000..2f8deaa3 --- /dev/null +++ b/src/linters/taplo.rs @@ -0,0 +1,83 @@ +use anyhow::Result; +use std::path::Path; + +use crate::registry::{CheckTypeDef, InitHookContext}; + +pub(crate) static CHECK_TYPE: CheckTypeDef = CheckTypeDef::with_init_hook("taplo", init); + +pub(crate) fn init(ctx: &dyn InitHookContext) -> Result { + generate_config(ctx.config_dir(), ctx.line_length()) +} + +pub(crate) fn generate_config(config_dir: &Path, line_length: u16) -> Result { + const SUPPORTED_CONFIG_NAMES: &[&str] = &[".taplo.toml"]; + const LEGACY_CONFIG_NAMES: &[&str] = &["taplo.toml"]; + if SUPPORTED_CONFIG_NAMES + .iter() + .map(|name| config_dir.join(name)) + .any(|path| path.exists()) + || LEGACY_CONFIG_NAMES + .iter() + .map(|name| config_dir.join(name)) + .any(|path| path.exists()) + { + return Ok(false); + } + let target = config_dir.join(".taplo.toml"); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + let content = [ + "[formatting]".to_string(), + format!("column_width = {line_length}"), + "indent_string = \" \"".to_string(), + ] + .join("\n") + + "\n"; + std::fs::write(&target, content)?; + println!(" wrote {}", target.display()); + Ok(true) +} + +pub(crate) fn normalize_nonverbose_failure_output( + argv: &[String], + stdout: &[u8], + stderr: &[u8], +) -> (Vec, Vec) { + let raw = format!( + "{}{}", + String::from_utf8_lossy(stdout), + String::from_utf8_lossy(stderr) + ); + let mut error_lines: Vec = raw + .lines() + .filter(|line| line.starts_with("ERROR")) + .map(ToOwned::to_owned) + .collect(); + + if error_lines.is_empty() + && let Some(target) = argv.last() + { + error_lines.push(format!( + "ERROR taplo:format_files: the file is not properly formatted path=\"{target}\"" + )); + } + + if !error_lines.is_empty() + && !error_lines.iter().any(|line| { + line == "ERROR operation failed error=some files were not properly formatted" + }) + { + error_lines.push( + "ERROR operation failed error=some files were not properly formatted".to_string(), + ); + } + + let stderr = if error_lines.is_empty() { + Vec::new() + } else { + format!("{}\n", error_lines.join("\n")).into_bytes() + }; + + (Vec::new(), stderr) +} diff --git a/src/linters/yamllint.rs b/src/linters/yamllint.rs new file mode 100644 index 00000000..81282256 --- /dev/null +++ b/src/linters/yamllint.rs @@ -0,0 +1,34 @@ +use anyhow::Result; +use std::path::Path; + +use crate::registry::{CheckTypeDef, InitHookContext}; + +pub(crate) static CHECK_TYPE: CheckTypeDef = CheckTypeDef::with_init_hook("yamllint", init); + +pub(crate) fn init(ctx: &dyn InitHookContext) -> Result { + generate_config(ctx.config_dir(), ctx.line_length()) +} + +pub(crate) fn generate_config(config_dir: &Path, line_length: u16) -> Result { + let target = config_dir.join(".yamllint.yml"); + if target.exists() { + return Ok(false); + } + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + let content = [ + "extends: relaxed", + "", + "rules:", + " document-start: disable", + " line-length:", + &format!(" max: {line_length}"), + " indentation: enable", + "", + ] + .join("\n"); + std::fs::write(&target, content)?; + println!(" wrote {}", target.display()); + Ok(true) +} diff --git a/src/main.rs b/src/main.rs index eed7e45f..6c0771ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ mod setup; use anyhow::Result; use clap::{Args, Parser, Subcommand}; -use registry::{CheckKind, FixBehavior, LinterConfig, RunPolicy, Scope, SpecialKind}; +use registry::{CheckKind, FixBehavior, LinterConfig, RunPolicy, Scope}; use runner::{CheckResult, RunOptions}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -207,9 +207,7 @@ async fn run( // --fast-only policy (skipped when linters are named explicitly, relevance-gated for // adaptive checks). mise guarantees declared tools are on PATH, so no PATH check needed. let mise_tools = registry::read_mise_tools(project_root); - let flint_setup_selected = checks - .iter() - .any(|c| c.kind.is_special_kind(SpecialKind::FlintSetup)); + let flint_setup_selected = checks.iter().any(|c| c.kind.is_setup()); if !flint_setup_selected { if let Some((old, new)) = registry::find_obsolete_key(&mise_tools) { eprintln!("flint: obsolete tool key in mise.toml: {old:?} (replaced by {new:?})"); @@ -225,6 +223,10 @@ async fn run( } let active: Vec<®istry::Check> = { let mut out = vec![]; + let relevance_ctx = AdaptiveRunContext { + file_list: &file_list, + project_root, + }; for c in checks { if registry::check_active(c, &mise_tools) { let include = if explicit || !args.fast_only { @@ -233,12 +235,9 @@ async fn run( match c.run_policy { RunPolicy::Fast => true, RunPolicy::Slow => false, - RunPolicy::Adaptive => match &c.kind { - kind if kind.is_special_kind(SpecialKind::RenovateDeps) => { - linters::renovate_deps::is_relevant(&file_list, project_root) - } - _ => true, - }, + RunPolicy::Adaptive => { + c.adaptive_relevance.is_none_or(|hook| hook(&relevance_ctx)) + } } }; if include { @@ -537,6 +536,31 @@ struct RunContext<'a> { config_dir: &'a Path, } +struct AdaptiveRunContext<'a> { + file_list: &'a files::FileList, + project_root: &'a Path, +} + +impl registry::AdaptiveRelevanceContext for AdaptiveRunContext<'_> { + fn file_list(&self) -> &files::FileList { + self.file_list + } + + fn project_root(&self) -> &Path { + self.project_root + } +} + +struct LinterStatusContext<'a> { + cfg: &'a config::Config, +} + +impl registry::StatusContext for LinterStatusContext<'_> { + fn config(&self) -> &config::Config { + self.cfg + } +} + enum FixOutcome { Clean, Fixed(String), @@ -618,7 +642,7 @@ fn classify_single_pass_fix(result: CheckResult) -> FixOutcome { } fn is_flint_setup(check: ®istry::Check) -> bool { - check.kind.is_special_kind(SpecialKind::FlintSetup) + check.kind.is_setup() } async fn run_checks( @@ -700,17 +724,14 @@ fn baseline_check_names( || registry::tool_version_changed(check, &previous_tools, current_tools) || flint_toml.as_ref().is_some_and(|change| { change.settings_changed - || (check.kind.special_kind().is_some() && change.check_changed(check.name)) + || (check.kind.is_native() && change.check_changed(check.name)) }) || check.baseline_config.as_ref().is_some_and(|config| { changed.contains(&config_file_rel_path(project_root, config_dir, config)) }) - || (check.name == "editorconfig-checker" - && changed.contains(&config_file_rel_path( - project_root, - config_dir, - ®istry::ConfigFile::project(".editorconfig"), - ))) + || check.baseline_triggers.iter().any(|config| { + changed.contains(&config_file_rel_path(project_root, config_dir, config)) + }) }) .map(|check| check.name.to_string()) .collect() @@ -1032,11 +1053,11 @@ where { if registry::check_active(check, mise_tools) { if !check.uses_binary() || binary_on_path(check.bin_name) { - if check.name == "license-header" && cfg.checks.license_header.text.is_empty() { - "not configured" - } else { - "active" - } + let status_ctx = LinterStatusContext { cfg }; + check + .status_hook + .and_then(|hook| hook(&status_ctx)) + .unwrap_or("active") } else { "no binary" } diff --git a/src/registry/checks.rs b/src/registry/checks.rs index cfad9f40..c8a0104b 100644 --- a/src/registry/checks.rs +++ b/src/registry/checks.rs @@ -1,5 +1,8 @@ -use super::types::{Check, ConfigFile, EditorconfigDirectiveStyle, SpecialKind}; -use crate::linters::renovate_deps::RENOVATE_CONFIG_PATTERNS; +use super::types::{Check, ConfigFile, EditorconfigDirectiveStyle, WorkflowSetup}; +use crate::linters::{ + biome, flint_setup, license_header, lychee, renovate_deps, + renovate_deps::RENOVATE_CONFIG_PATTERNS, rumdl, rustfmt, taplo, yamllint, +}; use crate::setup::{V1_BOOTSTRAP_SETUP_VERSION, V2_BASELINE_SETUP_VERSION}; const TOOL_RUMDL: &[&str] = &["tool", "rumdl"]; @@ -44,6 +47,8 @@ const EDITORCONFIG_CHECKER_UNSUPPORTED_CONFIGS: &[ConfigFile] = &[ ConfigFile::config_dir(".ecrc"), ConfigFile::project(".ecrc"), ]; +const EDITORCONFIG_CHECKER_BASELINE_TRIGGERS: &[ConfigFile] = + &[ConfigFile::project(".editorconfig")]; const GOLANGCI_LINT_UNSUPPORTED_CONFIGS: &[ConfigFile] = &[ ConfigFile::config_dir(".golangci.yaml"), ConfigFile::config_dir(".golangci.toml"), @@ -108,6 +113,7 @@ fn check_rumdl() -> Check { .linter_config(".rumdl.toml", "--config") .baseline_config(ConfigFile::config_dir(".rumdl.toml")) .unsupported_configs(RUMDL_UNSUPPORTED_CONFIGS) + .check_type(&rumdl::CHECK_TYPE) .nonverbose_filter_prefixes(&["Success: No issues found in "]) .formatter() .editorconfig_line_length_off( @@ -124,6 +130,7 @@ fn check_yaml_lint() -> Check { .linter_config(".yamllint.yml", "-c") .baseline_config(ConfigFile::config_dir(".yamllint.yml")) .unsupported_configs(YAMLLINT_UNSUPPORTED_CONFIGS) + .check_type(&yamllint::CHECK_TYPE) .formatter() .desc("Lint YAML files for style and consistency") .mise_tool("aqua:owenlamont/ryl") @@ -143,7 +150,9 @@ fn check_taplo() -> Check { .linter_config(".taplo.toml", "--config") .baseline_config(ConfigFile::config_dir(".taplo.toml")) .unsupported_configs(TAPLO_UNSUPPORTED_CONFIGS) + .check_type(&taplo::CHECK_TYPE) .stderr_filter_prefixes(&[" INFO taplo:"]) + .nonverbose_failure_output(taplo::normalize_nonverbose_failure_output) .formatter() .migrate_tool_keys_after(V2_BASELINE_SETUP_VERSION, &["github:tamasfe/taplo"]) .desc("Format TOML files") @@ -212,6 +221,7 @@ fn check_editorconfig_checker() -> Check { .mise_tool("editorconfig-checker") .defer_to_formatters() .linter_config(".editorconfig-checker.json", "-config") + .baseline_triggers(EDITORCONFIG_CHECKER_BASELINE_TRIGGERS) .unsupported_configs(EDITORCONFIG_CHECKER_UNSUPPORTED_CONFIGS) .desc("Check files comply with EditorConfig settings") } @@ -265,6 +275,7 @@ fn check_biome() -> Check { .fix("biome check --fix {FILE}") .baseline_config(BIOME_BASELINE_CONFIG) .unsupported_configs(BIOME_UNSUPPORTED_CONFIGS) + .check_type(&biome::CHECK_TYPE) .migrate_tool_keys_after(V2_BASELINE_SETUP_VERSION, &["npm:@biomejs/biome"]) .desc("Lint JS/TS/JSON files") .lang() @@ -280,6 +291,7 @@ fn check_biome_format() -> Check { .fix("biome format --write {FILE}") .baseline_config(BIOME_BASELINE_CONFIG) .unsupported_configs(BIOME_UNSUPPORTED_CONFIGS) + .check_type(&biome::CHECK_TYPE) .formatter() .desc("Format JS/TS/JSON files") .mise_tool("biome") @@ -296,6 +308,11 @@ fn check_cargo_clippy() -> Check { .partial_fix() .mise_tool("rust") .toolchain_components("clippy") + .missing_component_hint( + "clippy", + "'cargo-clippy' is not installed for the toolchain", + ) + .workflow_setup(WorkflowSetup::RustComponents) .desc("Lint Rust code; runs on all .rs files, not just changed") .lang() } @@ -306,9 +323,12 @@ fn check_cargo_fmt() -> Check { .linter_config("rustfmt.toml", "--config-path") .baseline_config(RUSTFMT_BASELINE_CONFIG) .unsupported_configs(RUSTFMT_UNSUPPORTED_CONFIGS) + .check_type(&rustfmt::CHECK_TYPE) .bin("rustfmt") .mise_tool("rust") .toolchain_components("rustfmt") + .missing_component_hint("rustfmt", "'rustfmt' is not installed for the toolchain") + .workflow_setup(WorkflowSetup::RustComponents) .formatter() .editorconfig_line_length_off(&["*.rs"], "Rust line length is handled by rustfmt", None) .desc("Format Rust code; runs on all .rs files, not just changed") @@ -383,7 +403,7 @@ fn check_dotnet_format() -> Check { } fn check_lychee() -> Check { - Check::special_with_bin("lychee", "lychee", SpecialKind::Links, false) + Check::native(&lychee::CHECK_TYPE) .desc("Check for broken links") .docs( "Orchestrates [lychee](https://lychee.cli.rs/) for link checking. \ @@ -394,6 +414,13 @@ fn check_lychee() -> Check { in all files — useful when broken internal links from unchanged files also\n\ matter.\n\ \n\ + In CI, `lychee` requires `GITHUB_TOKEN` so GitHub link checks can authenticate.\n\ + On GitHub Actions PR runs in changed-file mode, link remaps also require\n\ + `GITHUB_REPOSITORY`, `GITHUB_BASE_REF`, `GITHUB_HEAD_REF`, and `PR_HEAD_REPO`.\n\ + GitHub Actions provides the first three; set `PR_HEAD_REPO` from\n\ + `github.event.pull_request.head.repo.full_name`. `--full` does not require\n\ + the PR remap metadata.\n\ + \n\ Configure via `flint.toml`:\n\ \n\ ```toml\n\ @@ -405,8 +432,9 @@ fn check_lychee() -> Check { } fn check_renovate_deps() -> Check { - Check::special_with_bin("renovate-deps", "renovate", SpecialKind::RenovateDeps, true) + Check::native(&renovate_deps::CHECK_TYPE) .adaptive() + .adaptive_relevance(renovate_deps::adaptive_relevance) .mise_tool("npm:renovate") .patterns(RENOVATE_CONFIG_PATTERNS) .desc("Verify Renovate dependency snapshot is up to date") @@ -415,6 +443,14 @@ fn check_renovate_deps() -> Check { Renovate locally and comparing its output against the committed snapshot.\n\ Requires `renovate` in `[tools]`.\n\ \n\ + In CI, `renovate-deps` requires `GITHUB_COM_TOKEN` or `GITHUB_TOKEN`\n\ + so Renovate can authenticate GitHub requests. If `GITHUB_COM_TOKEN` is\n\ + unset, flint forwards `GITHUB_TOKEN` to Renovate as `GITHUB_COM_TOKEN`.\n\ + \n\ + When `flint init` writes a new `flint.toml`, it includes this section if\n\ + `renovate-deps` is selected. During v1 setup migration it also carries\n\ + legacy `RENOVATE_TRACKED_DEPS_EXCLUDE` values into `exclude_managers`.\n\ + \n\ With `--fix`, automatically regenerates and commits the snapshot.\n\ \n\ Configure via `flint.toml`:\n\ @@ -427,13 +463,14 @@ fn check_renovate_deps() -> Check { } fn check_license_header() -> Check { - Check::special("license-header", SpecialKind::LicenseHeader, false) + Check::native(&license_header::CHECK_TYPE) .activate_unconditionally() + .status_hook(license_header::status) .desc("Check source files have the required license header") } fn check_flint_setup() -> Check { - Check::special("flint-setup", SpecialKind::FlintSetup, true) + Check::native(&flint_setup::CHECK_TYPE) .activate_unconditionally() .patterns(&["mise.toml"]) .desc("Keep Flint setup current and mise.toml lint tooling canonical") diff --git a/src/registry/mod.rs b/src/registry/mod.rs index b519730f..ccd7aa2f 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -16,8 +16,11 @@ pub use obsolete::{ }; pub use resolve::binary_on_path; pub use types::{ - Category, Check, CheckKind, ConfigBase, ConfigFile, ConfigMatch, EditorconfigDirectiveStyle, - EditorconfigLineLengthPolicy, FixBehavior, LinterConfig, RunPolicy, Scope, SpecialKind, + AdaptiveRelevanceContext, Category, Check, CheckKind, CheckTypeDef, ConfigBase, ConfigFile, + ConfigMatch, EditorconfigDirectiveStyle, EditorconfigLineLengthPolicy, FixBehavior, + InitHookContext, LinterConfig, LinterOutput, MissingComponentHint, NativeCheck, NativeCheckDef, + NativePrepareContext, NativeRunContext, NativeRunFuture, NonverboseFailureOutputHook, + PreparedNativeCheck, RunPolicy, Scope, StatusContext, WorkflowSetup, }; /// Returns the explicit set of flint-managed tool keys that belong under the diff --git a/src/registry/tests.rs b/src/registry/tests.rs index fb3d5a0b..9d976965 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -178,7 +178,7 @@ fn normalized_command_prefix(check: &Check) -> Option { *check_cmd } } - crate::registry::CheckKind::Special(_) => return None, + crate::registry::CheckKind::Native(_) => return None, }; let mut words = vec![]; @@ -205,7 +205,7 @@ fn names_prefer_binary_or_native_command() { let violations: Vec = builtin() .into_iter() .filter(|check| check.uses_binary()) - .filter(|check| check.kind.special_kind().is_none()) + .filter(|check| !check.kind.is_native()) .filter_map(|check| { let allowed = ALLOWED_ALIASES .iter() @@ -355,6 +355,29 @@ fn editorconfig_checker_json_is_optional_not_generated_baseline() { check.baseline_config.is_none(), ".editorconfig-checker.json should not be treated as generated baseline config" ); + assert!( + check + .baseline_triggers + .iter() + .any(|config| config.path == ".editorconfig"), + ".editorconfig changes should trigger an all-files editorconfig-checker baseline" + ); +} + +#[test] +fn adaptive_checks_declare_relevance_hooks() { + let missing: Vec<_> = builtin() + .into_iter() + .filter(|check| check.run_policy == RunPolicy::Adaptive) + .filter(|check| check.adaptive_relevance.is_none()) + .map(|check| check.name) + .collect(); + + assert!( + missing.is_empty(), + "adaptive checks missing relevance hooks: {}", + missing.join(", ") + ); } #[test] @@ -857,8 +880,8 @@ fn detail_rows(check: &Check) -> Vec<(&'static str, String)> { match check.linter_config.as_ref() { Some(config) => rows.push(("Config", format!("`{}`", config.display_name()))), None => { - if check.kind.is_special_kind(SpecialKind::Links) { - rows.push(("Config", "via `[checks.links]` in flint.toml".to_string())); + if let Some(config) = check.kind.native_config_display() { + rows.push(("Config", config.to_string())); } } } diff --git a/src/registry/types.rs b/src/registry/types.rs index d608da8c..f9c57d32 100644 --- a/src/registry/types.rs +++ b/src/registry/types.rs @@ -1,3 +1,10 @@ +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; + +use crate::config::Config; +use crate::files::FileList; + /// How a check is invoked relative to the file list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Scope { @@ -45,38 +52,34 @@ pub enum RunPolicy { Adaptive, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SpecialKind { - Links, - RenovateDeps, - LicenseHeader, - FlintSetup, +#[derive(Debug, Clone, Copy)] +pub struct NativeCheckRef { + native: &'static dyn NativeCheck, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SpecialCheck { - kind: SpecialKind, - has_fix: bool, -} +impl NativeCheckRef { + fn new(native: &'static dyn NativeCheck) -> Self { + Self { native } + } + + pub fn has_fix(self) -> bool { + self.native.has_fix() + } -impl SpecialCheck { - fn new(kind: SpecialKind, has_fix: bool) -> Self { - Self { kind, has_fix } + pub fn uses_binary(self) -> bool { + self.native.uses_binary() } - pub fn kind(self) -> SpecialKind { - self.kind + pub fn prepare(self, ctx: NativePrepareContext<'_>) -> Option> { + self.native.prepare(ctx) } - pub fn has_fix(self) -> bool { - self.has_fix + pub fn config_display(self) -> Option<&'static str> { + self.native.config_display() } - pub fn uses_binary(self) -> bool { - !matches!( - self.kind, - SpecialKind::LicenseHeader | SpecialKind::FlintSetup - ) + pub fn is_setup(self) -> bool { + self.native.is_setup() } } @@ -100,26 +103,33 @@ pub enum CheckKind { full_fix_cmd: &'static str, scope: Scope, }, - Special(SpecialCheck), + Native(NativeCheckRef), } impl CheckKind { pub fn scope_name(&self) -> &'static str { match self { Self::Template { scope, .. } => scope.name(), - Self::Special(_) => "special", + Self::Native(_) => "native", } } - pub fn special_kind(&self) -> Option { + pub fn is_native(&self) -> bool { + matches!(self, Self::Native(_)) + } + + pub fn is_setup(&self) -> bool { match self { - Self::Template { .. } => None, - Self::Special(special) => Some(special.kind()), + Self::Template { .. } => false, + Self::Native(native) => native.is_setup(), } } - pub fn is_special_kind(&self, kind: SpecialKind) -> bool { - self.special_kind() == Some(kind) + pub fn native_config_display(&self) -> Option<&'static str> { + match self { + Self::Template { .. } => None, + Self::Native(native) => native.config_display(), + } } } @@ -176,6 +186,242 @@ pub struct ToolKeyMigration { pub old_key: &'static str, } +pub trait InitHookContext { + fn project_root(&self) -> &Path; + fn config_dir(&self) -> &Path; + fn line_length(&self) -> u16; + fn flint_toml_generated(&self) -> bool; + fn renovate_exclude_managers(&self) -> Option<&[String]>; +} + +pub type InitHookFn = fn(&dyn InitHookContext) -> anyhow::Result; + +/// Output from a single linter run. +pub struct LinterOutput { + pub ok: bool, + pub stdout: Vec, + pub stderr: Vec, +} + +impl LinterOutput { + pub fn err(stderr: impl Into>) -> Self { + Self { + ok: false, + stdout: vec![], + stderr: stderr.into(), + } + } +} + +pub trait CheckType: Sync + std::fmt::Debug { + fn name(&self) -> &'static str; + fn init_hook(&self) -> Option { + None + } + fn native_check(&'static self) -> Option<&'static dyn NativeCheck> { + None + } +} + +pub struct NativePrepareContext<'a> { + pub name: &'static str, + pub file_list: &'a FileList, + pub project_root: &'a Path, + pub cfg: &'a Config, + pub config_dir: &'a Path, +} + +pub struct NativeRunContext { + pub fix: bool, + pub project_root: PathBuf, +} + +pub type NativeRunFuture = Pin + Send>>; + +pub trait PreparedNativeCheck: Send + std::fmt::Debug { + fn name(&self) -> &str; + fn tracked_files(&self) -> &[PathBuf] { + &[] + } + fn run(self: Box, ctx: NativeRunContext) -> NativeRunFuture; +} + +pub type NativePrepareFn = fn(NativePrepareContext<'_>) -> Option>; + +pub trait NativeCheck: Sync + std::fmt::Debug { + fn prepare(&self, ctx: NativePrepareContext<'_>) -> Option>; + fn has_fix(&self) -> bool { + false + } + fn bin_name(&self) -> Option<&'static str> { + None + } + fn config_display(&self) -> Option<&'static str> { + None + } + fn is_setup(&self) -> bool { + false + } + fn uses_binary(&self) -> bool { + self.bin_name().is_some() + } +} + +#[derive(Debug, Clone, Copy)] +pub struct NativeCheckDef { + has_fix: bool, + bin_name: Option<&'static str>, + config_display: Option<&'static str>, + setup: bool, + prepare: NativePrepareFn, +} + +impl NativeCheckDef { + pub const fn new(prepare: NativePrepareFn) -> Self { + Self { + has_fix: false, + bin_name: None, + config_display: None, + setup: false, + prepare, + } + } + + pub const fn with_bin(bin_name: &'static str, prepare: NativePrepareFn) -> Self { + Self { + has_fix: false, + bin_name: Some(bin_name), + config_display: None, + setup: false, + prepare, + } + } + + pub const fn with_fix(mut self) -> Self { + self.has_fix = true; + self + } + + pub const fn with_config_display(mut self, config_display: &'static str) -> Self { + self.config_display = Some(config_display); + self + } + + pub const fn setup(mut self) -> Self { + self.setup = true; + self + } +} + +impl NativeCheck for NativeCheckDef { + fn prepare(&self, ctx: NativePrepareContext<'_>) -> Option> { + (self.prepare)(ctx) + } + + fn has_fix(&self) -> bool { + self.has_fix + } + + fn bin_name(&self) -> Option<&'static str> { + self.bin_name + } + + fn config_display(&self) -> Option<&'static str> { + self.config_display + } + + fn is_setup(&self) -> bool { + self.setup + } +} + +pub struct CheckTypeDef { + name: &'static str, + init_hook: Option, + native: Option, +} + +impl CheckTypeDef { + pub const fn with_init_hook(name: &'static str, init_hook: InitHookFn) -> Self { + Self { + name, + init_hook: Some(init_hook), + native: None, + } + } + + pub const fn native(name: &'static str, native: NativeCheckDef) -> Self { + Self { + name, + init_hook: None, + native: Some(native), + } + } + + pub const fn native_with_init_hook( + name: &'static str, + native: NativeCheckDef, + init_hook: InitHookFn, + ) -> Self { + Self { + name, + init_hook: Some(init_hook), + native: Some(native), + } + } +} + +impl CheckType for CheckTypeDef { + fn name(&self) -> &'static str { + self.name + } + + fn init_hook(&self) -> Option { + self.init_hook + } + + fn native_check(&'static self) -> Option<&'static dyn NativeCheck> { + self.native + .as_ref() + .map(|native| native as &dyn NativeCheck) + } +} + +impl std::fmt::Debug for CheckTypeDef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CheckTypeDef") + .field("name", &self.name) + .field("native", &self.native) + .finish() + } +} + +pub trait AdaptiveRelevanceContext { + fn file_list(&self) -> &FileList; + fn project_root(&self) -> &Path; +} + +pub type AdaptiveRelevanceHook = fn(&dyn AdaptiveRelevanceContext) -> bool; + +pub trait StatusContext { + fn config(&self) -> &Config; +} + +pub type StatusHook = fn(&dyn StatusContext) -> Option<&'static str>; + +pub type NonverboseFailureOutputHook = fn(&[String], &[u8], &[u8]) -> (Vec, Vec); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MissingComponentHint { + pub component: &'static str, + pub stderr_contains: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkflowSetup { + RustComponents, +} + #[derive(Debug, Clone)] pub struct Check { pub name: &'static str, @@ -218,6 +464,18 @@ pub struct Check { pub unsupported_configs: &'static [ConfigFile], /// Old mise tool keys that should migrate to this check's current install key. pub tool_key_migrations: Vec, + /// Optional check-type behavior shared by related checks. + pub check_type: Option<&'static dyn CheckType>, + /// Optional relevance hook for adaptive checks in `--fast-only` mode. + pub adaptive_relevance: Option, + /// Optional status override shown by `flint linters`. + pub status_hook: Option, + /// Optional output normalizer used for non-verbose failing process runs. + pub nonverbose_failure_output: Option, + /// Optional hint appended when a known toolchain component is missing. + pub missing_component_hint: Option, + /// Additional config-like files that trigger an all-files baseline run when changed. + pub baseline_triggers: &'static [ConfigFile], /// This check is a formatter — it owns certain file types for formatting purposes. pub is_formatter: bool, /// Skip files owned by active formatters (used by ec to avoid double-checking). @@ -240,6 +498,8 @@ pub struct Check { /// On Windows, the binary is a self-executing JAR that cannot be run directly /// or via cmd.exe — invoke as `java -jar ` instead. pub windows_java_jar: bool, + /// Extra generated workflow setup needed when this check is selected by `flint init`. + pub workflow_setup: Option, pub fix_behavior: FixBehavior, pub kind: CheckKind, /// Plain-text description of what the check does — shown in `flint linters` and the README table. @@ -252,7 +512,7 @@ impl Check { pub fn has_fix(&self) -> bool { match &self.kind { CheckKind::Template { fix_cmd, .. } => !fix_cmd.is_empty(), - CheckKind::Special(special) => special.has_fix(), + CheckKind::Native(native) => native.has_fix(), } } @@ -260,7 +520,7 @@ impl Check { pub fn uses_binary(&self) -> bool { match &self.kind { CheckKind::Template { .. } => true, - CheckKind::Special(special) => special.uses_binary(), + CheckKind::Native(native) => native.uses_binary(), } } @@ -343,6 +603,12 @@ impl Check { baseline_config: None, unsupported_configs: &[], tool_key_migrations: vec![], + check_type: None, + adaptive_relevance: None, + status_hook: None, + nonverbose_failure_output: None, + missing_component_hint: None, + baseline_triggers: &[], is_formatter: false, defers_to_formatters: false, editorconfig_line_length_policy: EditorconfigLineLengthPolicy::Default, @@ -358,27 +624,24 @@ impl Check { scope, }, windows_java_jar: false, + workflow_setup: None, fix_behavior: FixBehavior::Definitive, desc: "", docs: "", } } - /// Special check with custom logic (not a simple command template). - pub fn special(name: &'static str, kind: SpecialKind, has_fix: bool) -> Self { - Self::special_with_bin(name, "", kind, has_fix) - } - - /// Special check with custom logic backed by an external binary. - pub fn special_with_bin( - name: &'static str, - bin_name: &'static str, - kind: SpecialKind, - has_fix: bool, - ) -> Self { + /// Native check with custom logic (not a simple command template). + pub fn native(check_type: &'static dyn CheckType) -> Self { + let Some(native) = check_type.native_check() else { + panic!( + "native check '{}' has no native check implementation", + check_type.name() + ); + }; Check { - name, - bin_name, + name: check_type.name(), + bin_name: native.bin_name().unwrap_or(""), mise_tool_name: None, version_range: None, patterns: &[], @@ -390,6 +653,12 @@ impl Check { baseline_config: None, unsupported_configs: &[], tool_key_migrations: vec![], + check_type: Some(check_type), + adaptive_relevance: None, + status_hook: None, + nonverbose_failure_output: None, + missing_component_hint: None, + baseline_triggers: &[], is_formatter: false, defers_to_formatters: false, editorconfig_line_length_policy: EditorconfigLineLengthPolicy::Default, @@ -398,8 +667,9 @@ impl Check { run_policy: RunPolicy::Fast, toolchain: None, windows_java_jar: false, + workflow_setup: None, fix_behavior: FixBehavior::Definitive, - kind: CheckKind::Special(SpecialCheck::new(kind, has_fix)), + kind: CheckKind::Native(NativeCheckRef::new(native)), desc: "", docs: "", } @@ -528,7 +798,7 @@ impl Check { self } - /// Override the patterns field (useful for Special checks that need init-detection + /// Override the patterns field (useful for native checks that need init-detection /// patterns but don't use them for file matching at runtime). pub fn patterns(mut self, patterns: &'static [&'static str]) -> Self { self.patterns = patterns; @@ -611,6 +881,48 @@ impl Check { self } + pub fn check_type(mut self, check_type: &'static dyn CheckType) -> Self { + self.check_type = Some(check_type); + self + } + + pub fn adaptive_relevance(mut self, hook: AdaptiveRelevanceHook) -> Self { + self.adaptive_relevance = Some(hook); + self + } + + pub fn status_hook(mut self, hook: StatusHook) -> Self { + self.status_hook = Some(hook); + self + } + + pub fn nonverbose_failure_output(mut self, hook: NonverboseFailureOutputHook) -> Self { + self.nonverbose_failure_output = Some(hook); + self + } + + pub fn missing_component_hint( + mut self, + component: &'static str, + stderr_contains: &'static str, + ) -> Self { + self.missing_component_hint = Some(MissingComponentHint { + component, + stderr_contains, + }); + self + } + + pub fn baseline_triggers(mut self, files: &'static [ConfigFile]) -> Self { + self.baseline_triggers = files; + self + } + + pub fn workflow_setup(mut self, setup: WorkflowSetup) -> Self { + self.workflow_setup = Some(setup); + self + } + /// Old mise tool keys that should migrate to this check's current install /// key when the repo setup migration version is at or before /// `after_setup_migration_version`. diff --git a/src/runner.rs b/src/runner.rs index f02f0ed7..fed7c925 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -5,10 +5,13 @@ use std::process::Stdio; use std::time::{Duration, Instant}; use tokio::task::JoinSet; -use crate::config::{Config, LicenseHeaderConfig, LycheeConfig, RenovateDepsConfig, Settings}; -use crate::files::FileList; -use crate::linters::{LinterOutput, flint_setup, license_header, lychee, renovate_deps}; -use crate::registry::{Check, CheckKind, LinterConfig, Scope, SpecialKind}; +use crate::config::Config; +use crate::files::{FileList, match_files}; +use crate::linters::LinterOutput; +use crate::registry::{ + Check, CheckKind, LinterConfig, MissingComponentHint, NativePrepareContext, + NonverboseFailureOutputHook, Scope, +}; #[derive(Clone, Copy)] pub struct RunOptions { @@ -46,40 +49,17 @@ enum PreparedCheck { env: &'static [(&'static str, &'static str)], nonverbose_filter_prefixes: &'static [&'static str], stderr_filter_prefixes: &'static [&'static str], + nonverbose_failure_output: Option, + missing_component_hint: Option, }, - Links { - name: String, - cfg: LycheeConfig, - settings: Settings, - file_list: FileList, - config_dir: PathBuf, - }, - RenovateDeps { - name: String, - cfg: RenovateDepsConfig, - tracked_files: Vec, - }, - LicenseHeader { - name: String, - cfg: LicenseHeaderConfig, - files: Vec, - }, - FlintSetup { - name: String, - path: PathBuf, - config_dir: PathBuf, - setup_migration_version: u32, - }, + Native(Box), } impl PreparedCheck { fn name(&self) -> &str { match self { - Self::Invocations { name, .. } - | Self::Links { name, .. } - | Self::RenovateDeps { name, .. } - | Self::LicenseHeader { name, .. } - | Self::FlintSetup { name, .. } => name, + Self::Invocations { name, .. } => name, + Self::Native(native) => native.name(), } } @@ -94,6 +74,8 @@ impl PreparedCheck { env, nonverbose_filter_prefixes, stderr_filter_prefixes, + nonverbose_failure_output, + missing_component_hint, .. } => { let before = if fix && !tracked_files.is_empty() { @@ -115,6 +97,8 @@ impl PreparedCheck { }, stderr_filter_prefixes: if verbose { &[] } else { stderr_filter_prefixes }, }, + nonverbose_failure_output, + missing_component_hint, project_root, ) .await; @@ -122,46 +106,19 @@ impl PreparedCheck { before.is_some_and(|before| before != fingerprint_files(&tracked_files)); (out, changed) } - Self::Links { - cfg, - settings, - file_list, - config_dir, - .. - } => ( - lychee::run(&cfg, &settings, &file_list, project_root, &config_dir).await, - false, - ), - Self::RenovateDeps { - cfg, tracked_files, .. - } => { + Self::Native(native) => { + let tracked_files = native.tracked_files().to_vec(); let before = if fix && !tracked_files.is_empty() { Some(fingerprint_files(&tracked_files)) } else { None }; - let out = renovate_deps::run(&cfg, fix, project_root).await; - let changed = - before.is_some_and(|before| before != fingerprint_files(&tracked_files)); - (out, changed) - } - Self::LicenseHeader { cfg, files, .. } => { - (license_header::run(&cfg, project_root, &files).await, false) - } - Self::FlintSetup { - path, - config_dir, - setup_migration_version, - .. - } => { - let tracked_files = vec![path.clone(), config_dir.join("flint.toml")]; - let before = if fix { - Some(fingerprint_files(&tracked_files)) - } else { - None - }; - let out = - flint_setup::run(fix, project_root, &config_dir, setup_migration_version).await; + let out = native + .run(crate::registry::NativeRunContext { + fix, + project_root: project_root.to_path_buf(), + }) + .await; let changed = before.is_some_and(|before| before != fingerprint_files(&tracked_files)); (out, changed) @@ -282,56 +239,19 @@ fn prepare( env: check.env, nonverbose_filter_prefixes: check.nonverbose_filter_prefixes, stderr_filter_prefixes: check.stderr_filter_prefixes, + nonverbose_failure_output: check.nonverbose_failure_output, + missing_component_hint: check.missing_component_hint, }) } - CheckKind::Special(special) => match special.kind() { - SpecialKind::Links => Some(PreparedCheck::Links { - name, - cfg: cfg.checks.lychee.clone(), - settings: cfg.settings.clone(), - file_list: file_list.clone(), - config_dir: config_dir.to_path_buf(), - }), - SpecialKind::RenovateDeps => Some(PreparedCheck::RenovateDeps { - name, - cfg: cfg.checks.renovate_deps.clone(), - tracked_files: renovate_deps::COMMITTED_PATHS - .iter() - .map(|path| project_root.join(path)) - .collect(), - }), - SpecialKind::LicenseHeader => { - if cfg.checks.license_header.text.is_empty() { - return None; - } - let patterns: Vec<&str> = cfg - .checks - .license_header - .patterns - .iter() - .map(String::as_str) - .collect(); - let files: Vec = - match_files(&file_list.files, &patterns, &[], project_root) - .into_iter() - .cloned() - .collect(); - if files.is_empty() { - return None; - } - Some(PreparedCheck::LicenseHeader { - name, - cfg: cfg.checks.license_header.clone(), - files, - }) - } - SpecialKind::FlintSetup => Some(PreparedCheck::FlintSetup { - name, - path: project_root.join("mise.toml"), - config_dir: config_dir.to_path_buf(), - setup_migration_version: cfg.settings.setup_migration_version, - }), - }, + CheckKind::Native(native) => native + .prepare(NativePrepareContext { + name: check.name, + file_list, + project_root, + cfg, + config_dir, + }) + .map(PreparedCheck::Native), } } @@ -573,6 +493,8 @@ async fn run_invocations( invocations: &[Vec], windows_java_jar: bool, output_policy: InvocationOutputPolicy<'_>, + nonverbose_failure_output: Option, + missing_component_hint: Option, root: &Path, ) -> LinterOutput { let mut all_ok = true; @@ -590,12 +512,11 @@ async fn run_invocations( let result = cmd.output().await; match result { Ok(out) => { - if name == "taplo" - && !output_policy.stderr_filter_prefixes.is_empty() + if output_policy.nonverbose && !out.status.success() + && let Some(normalize) = nonverbose_failure_output { - let (stdout, stderr) = - normalize_taplo_nonverbose_output(argv, &out.stdout, &out.stderr); + let (stdout, stderr) = normalize(argv, &out.stdout, &out.stderr); combined_stdout.extend_from_slice(&stdout); combined_stderr.extend_from_slice(&stderr); } else { @@ -643,7 +564,7 @@ async fn run_invocations( } } - maybe_append_rust_component_note(name, &mut combined_stderr); + maybe_append_missing_component_note(name, missing_component_hint, &mut combined_stderr); LinterOutput { ok: all_ok, @@ -674,49 +595,6 @@ fn filter_stderr_lines(stderr: &[u8], prefixes: &[&str]) -> Vec { out.into_bytes() } -fn normalize_taplo_nonverbose_output( - argv: &[String], - stdout: &[u8], - stderr: &[u8], -) -> (Vec, Vec) { - let raw = format!( - "{}{}", - String::from_utf8_lossy(stdout), - String::from_utf8_lossy(stderr) - ); - let mut error_lines: Vec = raw - .lines() - .filter(|line| line.starts_with("ERROR")) - .map(ToOwned::to_owned) - .collect(); - - if error_lines.is_empty() - && let Some(target) = argv.last() - { - error_lines.push(format!( - "ERROR taplo:format_files: the file is not properly formatted path=\"{target}\"" - )); - } - - if !error_lines.is_empty() - && !error_lines.iter().any(|line| { - line == "ERROR operation failed error=some files were not properly formatted" - }) - { - error_lines.push( - "ERROR operation failed error=some files were not properly formatted".to_string(), - ); - } - - let stderr = if error_lines.is_empty() { - Vec::new() - } else { - format!("{}\n", error_lines.join("\n")).into_bytes() - }; - - (Vec::new(), stderr) -} - fn filter_output_lines(output: &[u8], predicate: impl Fn(&str) -> bool) -> Vec { let text = String::from_utf8_lossy(output); let mut out = String::new(); @@ -739,14 +617,23 @@ fn filter_output_lines(output: &[u8], predicate: impl Fn(&str) -> bool) -> Vec) { - let Some(component) = missing_rust_component(name, stderr) else { +fn maybe_append_missing_component_note( + name: &str, + hint: Option, + stderr: &mut Vec, +) { + let Some(hint) = hint else { + return; + }; + let stderr_text = String::from_utf8_lossy(stderr); + if !stderr_text.contains(hint.stderr_contains) { return; }; let note = format!( "NOTE: `{name}` needs the Rust `{component}` component in the active toolchain.\n\ `mise` may activate an existing Rust toolchain without adding missing components.\n\ -Install it with: `rustup component add {component}`\n" +Install it with: `rustup component add {component}`\n", + component = hint.component, ); stderr.extend_from_slice(note.as_bytes()); } @@ -762,19 +649,6 @@ fn fingerprint_files(files: &[PathBuf]) -> u64 { hasher.finish() } -fn missing_rust_component(name: &str, stderr: &[u8]) -> Option<&'static str> { - let stderr = String::from_utf8_lossy(stderr); - match name { - "cargo-clippy" if stderr.contains("'cargo-clippy' is not installed for the toolchain") => { - Some("clippy") - } - "cargo-fmt" if stderr.contains("'rustfmt' is not installed for the toolchain") => { - Some("rustfmt") - } - _ => None, - } -} - fn format_duration_suffix(time: bool, duration: Duration) -> String { if !time { return String::new(); @@ -798,57 +672,6 @@ fn flush_output(stdout: &[u8], stderr: &[u8]) { } } -fn match_files<'a>( - files: &'a [PathBuf], - patterns: &[&str], - exclude_patterns: &[&str], - project_root: &Path, -) -> Vec<&'a PathBuf> { - files - .iter() - .filter(|p| { - let rel = p.strip_prefix(project_root).unwrap_or(p); - let rel_str = rel.to_string_lossy(); - let file_name = p - .file_name() - .map(|n| n.to_string_lossy()) - .unwrap_or_default(); - let included = patterns.iter().any(|pat| { - if *pat == "*" { - return true; - } - glob_match(pat, file_name.as_ref()) || glob_match(pat, rel_str.as_ref()) - }); - let excluded = exclude_patterns.iter().any(|pat| { - glob_match(pat, file_name.as_ref()) || glob_match(pat, rel_str.as_ref()) - }); - included && !excluded - }) - .collect() -} - -fn glob_match(pattern: &str, name: &str) -> bool { - // Simple glob: splits on `*` and checks that each segment appears in order. - // Handles `*.ext`, `prefix*`, `dir/*.yml`, etc. - let parts: Vec<&str> = pattern.splitn(2, '*').collect(); - match parts.as_slice() { - [only] => name == *only || name.ends_with(&format!("/{only}")), - [prefix, suffix] => { - let n = name; - // The prefix must match the start of the name (or the part after the last slash). - let anchor_start = prefix.is_empty() || n.starts_with(prefix) || { - // Allow matching the basename portion for patterns like `*.sh`. - n.contains('/') && { - let after_slash = n.rfind('/').map(|i| &n[i + 1..]).unwrap_or(n); - prefix.is_empty() || after_slash.starts_with(prefix) - } - }; - anchor_start && n.ends_with(suffix) - } - _ => false, - } -} - fn substitute_merge_base(cmd: &str, merge_base: Option<&str>) -> String { if let Some(base) = merge_base { cmd.replace("{MERGE_BASE}", base) @@ -1010,6 +833,12 @@ mod tests { baseline_config: None, unsupported_configs: &[], tool_key_migrations: vec![], + check_type: None, + adaptive_relevance: None, + status_hook: None, + nonverbose_failure_output: None, + missing_component_hint: None, + baseline_triggers: &[], is_formatter: false, defers_to_formatters: false, editorconfig_line_length_policy: crate::registry::EditorconfigLineLengthPolicy::Default, @@ -1018,6 +847,7 @@ mod tests { run_policy: RunPolicy::Fast, toolchain: None, windows_java_jar: false, + workflow_setup: None, fix_behavior: crate::registry::FixBehavior::Definitive, kind: CheckKind::Template { check_cmd: "run-it", @@ -1124,7 +954,14 @@ mod tests { fn appends_rust_component_note_for_missing_clippy() { let mut stderr = b"error: 'cargo-clippy' is not installed for the toolchain '1.94.1-x86_64-unknown-linux-gnu'.\n".to_vec(); - maybe_append_rust_component_note("cargo-clippy", &mut stderr); + maybe_append_missing_component_note( + "cargo-clippy", + Some(MissingComponentHint { + component: "clippy", + stderr_contains: "'cargo-clippy' is not installed for the toolchain", + }), + &mut stderr, + ); let msg = String::from_utf8(stderr).unwrap(); assert!(msg.contains("NOTE: `cargo-clippy` needs the Rust `clippy` component")); @@ -1136,7 +973,14 @@ mod tests { let mut stderr = b"error: 'rustfmt' is not installed for the toolchain '1.94.1-x86_64-unknown-linux-gnu'.\n".to_vec(); - maybe_append_rust_component_note("cargo-fmt", &mut stderr); + maybe_append_missing_component_note( + "cargo-fmt", + Some(MissingComponentHint { + component: "rustfmt", + stderr_contains: "'rustfmt' is not installed for the toolchain", + }), + &mut stderr, + ); let msg = String::from_utf8(stderr).unwrap(); assert!(msg.contains("NOTE: `cargo-fmt` needs the Rust `rustfmt` component")); diff --git a/tests/cases/general/fast-only-explicit-override/test.toml b/tests/cases/general/fast-only-explicit-override/test.toml index 5aecd69a..e26ee4f3 100644 --- a/tests/cases/general/fast-only-explicit-override/test.toml +++ b/tests/cases/general/fast-only-explicit-override/test.toml @@ -3,6 +3,7 @@ [expected] args = "run --full --fast-only renovate-deps" exit = 0 +stderr = "flint: warning: GITHUB_COM_TOKEN or GITHUB_TOKEN is not set; renovate-deps GitHub requests may be rate limited\n" [expected.files] ".renovate-ran" = """ diff --git a/tests/cases/general/init-rust/test.toml b/tests/cases/general/init-rust/test.toml index e9054b46..1ba31718 100644 --- a/tests/cases/general/init-rust/test.toml +++ b/tests/cases/general/init-rust/test.toml @@ -8,13 +8,13 @@ Tip: flint init detects languages from tracked files (`git ls-files`). Add and s normalized [tools] in /mise.toml wrote /.github/config/flint.toml wrote /.github/workflows/lint.yml + wrote /biome.jsonc + wrote /.github/config/rustfmt.toml removed /.markdownlint.json (replaced by .rumdl.toml) wrote /.github/config/.rumdl.toml + wrote /.github/config/.yamllint.yml patched /.editorconfig — set max_line_length = 120 patched /.editorconfig — disable max_line_length for [*.rs], [*.md] - wrote /.github/config/.yamllint.yml - wrote /.github/config/rustfmt.toml - wrote /biome.jsonc installed pre-commit hook (.git/hooks/pre-commit) Done. Run `mise install` to install the new tools. ''' diff --git a/tests/cases/lychee/broken-link/test.toml b/tests/cases/lychee/broken-link/test.toml index 2635ccba..02d54c36 100644 --- a/tests/cases/lychee/broken-link/test.toml +++ b/tests/cases/lychee/broken-link/test.toml @@ -16,4 +16,19 @@ flint: 1 check failed (lychee) ''' [env] +GITHUB_TOKEN = "token" LYCHEE_SKIP_GITHUB_REMAPS = "true" + +[fake_bins] +lychee = ''' +#!/bin/sh +cat >&2 <<'EOF' +Issues found in 1 input. Find details below. + +[README.md]: +[ERROR] file:///nonexistent.md | Cannot find file: File not found. Check if file exists and path is correct + +🔍 1 Total (in Xs) ✅ 0 OK 🚫 1 Error +EOF +exit 1 +''' diff --git a/tests/cases/lychee/ci-full-missing-remap-env/files/README.md b/tests/cases/lychee/ci-full-missing-remap-env/files/README.md new file mode 100644 index 00000000..058bbb45 --- /dev/null +++ b/tests/cases/lychee/ci-full-missing-remap-env/files/README.md @@ -0,0 +1,3 @@ +# Links + +No links here. diff --git a/tests/cases/lychee/ci-full-missing-remap-env/files/lychee.toml b/tests/cases/lychee/ci-full-missing-remap-env/files/lychee.toml new file mode 100644 index 00000000..6f3ae43c --- /dev/null +++ b/tests/cases/lychee/ci-full-missing-remap-env/files/lychee.toml @@ -0,0 +1 @@ +# lychee configuration diff --git a/tests/cases/lychee/ci-full-missing-remap-env/files/mise.toml b/tests/cases/lychee/ci-full-missing-remap-env/files/mise.toml new file mode 100644 index 00000000..450fc84a --- /dev/null +++ b/tests/cases/lychee/ci-full-missing-remap-env/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +lychee = "latest" diff --git a/tests/cases/lychee/ci-full-missing-remap-env/test.toml b/tests/cases/lychee/ci-full-missing-remap-env/test.toml new file mode 100644 index 00000000..54f89213 --- /dev/null +++ b/tests/cases/lychee/ci-full-missing-remap-env/test.toml @@ -0,0 +1,14 @@ +[expected] +args = "run --full lychee" +exit = 0 + +[env] +CI = "true" +GITHUB_EVENT_NAME = "pull_request" +GITHUB_TOKEN = "token" + +[fake_bins] +lychee = ''' +#!/bin/sh +exit 0 +''' diff --git a/tests/cases/lychee/ci-missing-token/files/README.md b/tests/cases/lychee/ci-missing-token/files/README.md new file mode 100644 index 00000000..058bbb45 --- /dev/null +++ b/tests/cases/lychee/ci-missing-token/files/README.md @@ -0,0 +1,3 @@ +# Links + +No links here. diff --git a/tests/cases/lychee/ci-missing-token/files/lychee.toml b/tests/cases/lychee/ci-missing-token/files/lychee.toml new file mode 100644 index 00000000..6f3ae43c --- /dev/null +++ b/tests/cases/lychee/ci-missing-token/files/lychee.toml @@ -0,0 +1 @@ +# lychee configuration diff --git a/tests/cases/lychee/ci-missing-token/files/mise.toml b/tests/cases/lychee/ci-missing-token/files/mise.toml new file mode 100644 index 00000000..450fc84a --- /dev/null +++ b/tests/cases/lychee/ci-missing-token/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +lychee = "latest" diff --git a/tests/cases/lychee/ci-missing-token/test.toml b/tests/cases/lychee/ci-missing-token/test.toml new file mode 100644 index 00000000..e82f9e19 --- /dev/null +++ b/tests/cases/lychee/ci-missing-token/test.toml @@ -0,0 +1,7 @@ +[expected] +args = "run --full lychee" +exit = 1 +stderr_contains = ["flint: links: missing required CI environment variable: GITHUB_TOKEN", "Set GITHUB_TOKEN so lychee can authenticate GitHub link checks in CI."] + +[env] +CI = "true" diff --git a/tests/cases/lychee/ci-pr-missing-remap-env/changes/README.md b/tests/cases/lychee/ci-pr-missing-remap-env/changes/README.md new file mode 100644 index 00000000..3baf91a1 --- /dev/null +++ b/tests/cases/lychee/ci-pr-missing-remap-env/changes/README.md @@ -0,0 +1,3 @@ +# Links + +Changed file. diff --git a/tests/cases/lychee/ci-pr-missing-remap-env/files/README.md b/tests/cases/lychee/ci-pr-missing-remap-env/files/README.md new file mode 100644 index 00000000..058bbb45 --- /dev/null +++ b/tests/cases/lychee/ci-pr-missing-remap-env/files/README.md @@ -0,0 +1,3 @@ +# Links + +No links here. diff --git a/tests/cases/lychee/ci-pr-missing-remap-env/files/lychee.toml b/tests/cases/lychee/ci-pr-missing-remap-env/files/lychee.toml new file mode 100644 index 00000000..6f3ae43c --- /dev/null +++ b/tests/cases/lychee/ci-pr-missing-remap-env/files/lychee.toml @@ -0,0 +1 @@ +# lychee configuration diff --git a/tests/cases/lychee/ci-pr-missing-remap-env/files/mise.toml b/tests/cases/lychee/ci-pr-missing-remap-env/files/mise.toml new file mode 100644 index 00000000..450fc84a --- /dev/null +++ b/tests/cases/lychee/ci-pr-missing-remap-env/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +lychee = "latest" diff --git a/tests/cases/lychee/ci-pr-missing-remap-env/test.toml b/tests/cases/lychee/ci-pr-missing-remap-env/test.toml new file mode 100644 index 00000000..d246ecb4 --- /dev/null +++ b/tests/cases/lychee/ci-pr-missing-remap-env/test.toml @@ -0,0 +1,9 @@ +[expected] +args = "run lychee" +exit = 1 +stderr_contains = ["flint: links: missing required CI environment variables: GITHUB_REPOSITORY, GITHUB_BASE_REF, GITHUB_HEAD_REF, PR_HEAD_REPO", "PR link remaps in CI require GitHub PR metadata"] + +[env] +CI = "true" +GITHUB_EVENT_NAME = "pull_request" +GITHUB_TOKEN = "token" diff --git a/tests/cases/lychee/clean/test.toml b/tests/cases/lychee/clean/test.toml index 0355a9f2..2e308929 100644 --- a/tests/cases/lychee/clean/test.toml +++ b/tests/cases/lychee/clean/test.toml @@ -4,4 +4,11 @@ exit = 0 [env] +GITHUB_TOKEN = "token" LYCHEE_SKIP_GITHUB_REMAPS = "true" + +[fake_bins] +lychee = ''' +#!/bin/sh +exit 0 +''' diff --git a/tests/cases/lychee/local-missing-token-warns/files/README.md b/tests/cases/lychee/local-missing-token-warns/files/README.md new file mode 100644 index 00000000..058bbb45 --- /dev/null +++ b/tests/cases/lychee/local-missing-token-warns/files/README.md @@ -0,0 +1,3 @@ +# Links + +No links here. diff --git a/tests/cases/lychee/local-missing-token-warns/files/lychee.toml b/tests/cases/lychee/local-missing-token-warns/files/lychee.toml new file mode 100644 index 00000000..6f3ae43c --- /dev/null +++ b/tests/cases/lychee/local-missing-token-warns/files/lychee.toml @@ -0,0 +1 @@ +# lychee configuration diff --git a/tests/cases/lychee/local-missing-token-warns/files/mise.toml b/tests/cases/lychee/local-missing-token-warns/files/mise.toml new file mode 100644 index 00000000..450fc84a --- /dev/null +++ b/tests/cases/lychee/local-missing-token-warns/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +lychee = "latest" diff --git a/tests/cases/lychee/local-missing-token-warns/test.toml b/tests/cases/lychee/local-missing-token-warns/test.toml new file mode 100644 index 00000000..7109b636 --- /dev/null +++ b/tests/cases/lychee/local-missing-token-warns/test.toml @@ -0,0 +1,12 @@ +[expected] +args = "run --full lychee" +exit = 0 +stderr = ''' +flint: warning: GITHUB_TOKEN is not set; lychee GitHub requests may be rate limited +''' + +[fake_bins] +lychee = ''' +#!/bin/sh +exit 0 +''' diff --git a/tests/cases/renovate-deps/ci-missing-token/files/mise.toml b/tests/cases/renovate-deps/ci-missing-token/files/mise.toml new file mode 100644 index 00000000..1faa014b --- /dev/null +++ b/tests/cases/renovate-deps/ci-missing-token/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"npm:renovate" = "43.136.3" diff --git a/tests/cases/renovate-deps/ci-missing-token/files/renovate.json5 b/tests/cases/renovate-deps/ci-missing-token/files/renovate.json5 new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/cases/renovate-deps/ci-missing-token/files/renovate.json5 @@ -0,0 +1 @@ +{} diff --git a/tests/cases/renovate-deps/ci-missing-token/test.toml b/tests/cases/renovate-deps/ci-missing-token/test.toml new file mode 100644 index 00000000..b5bd59e9 --- /dev/null +++ b/tests/cases/renovate-deps/ci-missing-token/test.toml @@ -0,0 +1,14 @@ +[expected] +args = "run --full renovate-deps" +exit = 1 +stderr_contains = ["flint: renovate-deps: missing required CI environment variable: GITHUB_COM_TOKEN or GITHUB_TOKEN", "Set GITHUB_TOKEN, or set GITHUB_COM_TOKEN directly"] + +[env] +CI = "true" + +[fake_bins] +renovate = ''' +#!/bin/sh +echo "renovate should not run without token on CI" >&2 +exit 1 +''' diff --git a/tests/cases/renovate-deps/fast-only-irrelevant/test.toml b/tests/cases/renovate-deps/fast-only-irrelevant/test.toml index 88dbae91..e21b1ec9 100644 --- a/tests/cases/renovate-deps/fast-only-irrelevant/test.toml +++ b/tests/cases/renovate-deps/fast-only-irrelevant/test.toml @@ -2,6 +2,9 @@ args = "run --fast-only" exit = 0 +[env] +GITHUB_TOKEN = "token" + [fake_bins] renovate = ''' #!/bin/sh diff --git a/tests/cases/renovate-deps/fast-only-relevant/test.toml b/tests/cases/renovate-deps/fast-only-relevant/test.toml index e461e4f1..fdff2a81 100644 --- a/tests/cases/renovate-deps/fast-only-relevant/test.toml +++ b/tests/cases/renovate-deps/fast-only-relevant/test.toml @@ -6,6 +6,9 @@ exit = 0 ".renovate-ran" = """ """ +[env] +GITHUB_TOKEN = "token" + [fake_bins] renovate = ''' #!/bin/sh diff --git a/tests/cases/renovate-deps/fix-create/test.toml b/tests/cases/renovate-deps/fix-create/test.toml index ad508641..dd181064 100644 --- a/tests/cases/renovate-deps/fix-create/test.toml +++ b/tests/cases/renovate-deps/fix-create/test.toml @@ -5,6 +5,9 @@ stderr = ''' flint: fixed: renovate-deps — commit before pushing ''' +[env] +GITHUB_TOKEN = "token" + [expected.files] ".github/renovate-tracked-deps.json" = """ { @@ -20,4 +23,4 @@ flint: fixed: renovate-deps — commit before pushing ] } } -""" \ No newline at end of file +""" diff --git a/tests/cases/renovate-deps/fix-update/test.toml b/tests/cases/renovate-deps/fix-update/test.toml index ad508641..dd181064 100644 --- a/tests/cases/renovate-deps/fix-update/test.toml +++ b/tests/cases/renovate-deps/fix-update/test.toml @@ -5,6 +5,9 @@ stderr = ''' flint: fixed: renovate-deps — commit before pushing ''' +[env] +GITHUB_TOKEN = "token" + [expected.files] ".github/renovate-tracked-deps.json" = """ { @@ -20,4 +23,4 @@ flint: fixed: renovate-deps — commit before pushing ] } } -""" \ No newline at end of file +""" diff --git a/tests/cases/renovate-deps/local-missing-token-warns/files/mise.toml b/tests/cases/renovate-deps/local-missing-token-warns/files/mise.toml new file mode 100644 index 00000000..1faa014b --- /dev/null +++ b/tests/cases/renovate-deps/local-missing-token-warns/files/mise.toml @@ -0,0 +1,2 @@ +[tools] +"npm:renovate" = "43.136.3" diff --git a/tests/cases/renovate-deps/local-missing-token-warns/files/renovate.json5 b/tests/cases/renovate-deps/local-missing-token-warns/files/renovate.json5 new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests/cases/renovate-deps/local-missing-token-warns/files/renovate.json5 @@ -0,0 +1 @@ +{} diff --git a/tests/cases/renovate-deps/local-missing-token-warns/test.toml b/tests/cases/renovate-deps/local-missing-token-warns/test.toml new file mode 100644 index 00000000..bdfd610d --- /dev/null +++ b/tests/cases/renovate-deps/local-missing-token-warns/test.toml @@ -0,0 +1,10 @@ +[expected] +args = "run --full renovate-deps" +exit = 1 +stderr_contains = ["flint: warning: GITHUB_COM_TOKEN or GITHUB_TOKEN is not set; renovate-deps GitHub requests may be rate limited", "ERROR: renovate-tracked-deps.json does not exist."] + +[fake_bins] +renovate = ''' +#!/bin/sh +printf '%s\n' '{"msg":"Extracted dependencies","packageFiles":{"mise":[{"packageFile":"mise.toml","deps":[{"depName":"npm:renovate"}]}]}}' +''' diff --git a/tests/cases/renovate-deps/out-of-date/test.toml b/tests/cases/renovate-deps/out-of-date/test.toml index 45618723..28f8ba42 100644 --- a/tests/cases/renovate-deps/out-of-date/test.toml +++ b/tests/cases/renovate-deps/out-of-date/test.toml @@ -27,6 +27,9 @@ flint: 1 check failed (renovate-deps) 💡 Try `flint run --fix` to auto-fix lint issues, then re-run `flint run` to verify. ''' +[env] +GITHUB_TOKEN = "token" + [expected.files] ".github/renovate-tracked-deps.json" = """ { @@ -36,4 +39,4 @@ flint: 1 check failed (renovate-deps) ] } } -""" \ No newline at end of file +""" diff --git a/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml b/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml index d8b049bd..c507d5d8 100644 --- a/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml +++ b/tests/cases/renovate-deps/up-to-date-renovaterc-json/test.toml @@ -1,3 +1,6 @@ [expected] args = "run --full renovate-deps" exit = 0 + +[env] +GITHUB_TOKEN = "token" diff --git a/tests/cases/renovate-deps/up-to-date/test.toml b/tests/cases/renovate-deps/up-to-date/test.toml index d8b049bd..c507d5d8 100644 --- a/tests/cases/renovate-deps/up-to-date/test.toml +++ b/tests/cases/renovate-deps/up-to-date/test.toml @@ -1,3 +1,6 @@ [expected] args = "run --full renovate-deps" exit = 0 + +[env] +GITHUB_TOKEN = "token" diff --git a/tests/e2e.rs b/tests/e2e.rs index 335c3b3b..e7cf233a 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -15,6 +15,13 @@ fn flint_with_env(args: &[&str], cwd: &Path, env: &[(&str, &str)]) -> Output { .env_remove("GITHUB_ACTIONS") .env_remove("GITHUB_ACTION") .env_remove("GITHUB_WORKFLOW") + .env_remove("GITHUB_COM_TOKEN") + .env_remove("GITHUB_TOKEN") + .env_remove("GITHUB_EVENT_NAME") + .env_remove("GITHUB_REPOSITORY") + .env_remove("GITHUB_BASE_REF") + .env_remove("GITHUB_HEAD_REF") + .env_remove("PR_HEAD_REPO") .current_dir(cwd); for (k, v) in env { cmd.env(k, v);