Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/agents/knowledge/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ A check is expanded to all matching files when:
- it was not active at the merge base, meaning its tool was newly added to
`mise.toml`
- its resolved tool version changed in `mise.toml`
- the pinned `github:grafana/flint` version changed in `mise.toml`; this expands
every active check because the runner/orchestrator changed
- its registered `.linter_config(...)` file changed under `FLINT_CONFIG_DIR`
- another supported baseline config changed, such as `.editorconfig` for
`editorconfig-checker`
- `flint.toml` changed under `[settings]`
- `flint.toml` changed the check-specific section for a special check

Expand All @@ -57,3 +61,8 @@ Explicit `--full` bypasses this selection because every check is already using
the all-files list. Config-change triggers use the raw git change list before
`settings.exclude` is applied, so excluded config paths still expand the affected
check.

For linters where flint passes a config file explicitly, the registered
flint-managed file is authoritative. Known alternate upstream config files are
hard failures for active checks, including section-based configs like
`pyproject.toml` only when the relevant tool section exists.
7 changes: 7 additions & 0 deletions .github/agents/knowledge/linters.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ Check::file("rumdl", "rumdl check {FILE}", &["*.md"])
Look up the tool's `--help` or man page for the config flag name and expected
argument type before adding `.linter_config`.

When a tool supports other config filenames, register them with
`.unsupported_configs(...)` so flint fails loudly instead of letting the tool
auto-discover a config that flint does not baseline or inject. Use
`.baseline_configs(...)` for config-like files that should force an all-files
run when changed, even if they are not passed via `.linter_config(...)`; for
example, `editorconfig-checker` treats `.editorconfig` as a baseline config.

For checks that need custom logic (not a simple command template), add a module
under `src/linters/` and use `CheckKind::Special`.

Expand Down
10 changes: 10 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@ A check runs against all matching files when:

- the check is newly active because its tool was added to `mise.toml`
- the check's tool version changed in `mise.toml`
- the pinned `github:grafana/flint` version changed in `mise.toml`, which
expands all active checks
- the check's flint-managed config file changed, such as `.shellcheckrc` or
`.yamllint.yml` in `FLINT_CONFIG_DIR`
- 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
`[checks.links]` or `[checks.renovate-deps]`
Expand All @@ -60,6 +64,12 @@ have changed. Config-file triggers are detected from the raw git change list, so
they still apply when the config path itself is excluded from ordinary lint file
selection.

Flint intentionally supports one canonical config filename per linter when it
passes config paths explicitly. If an active linter has a known alternate
upstream config file, Flint fails before running the linter instead of silently
ignoring or partially auto-discovering that config. Move the config to the
Flint-managed filename under `FLINT_CONFIG_DIR`, or remove the alternate file.

**`--short` output** — failed checks partitioned by fixability, fixable ones
expressed as the exact command to run:

Expand Down
86 changes: 84 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,20 @@ async fn run(
out
};

if let Some((check, config)) = active.iter().find_map(|check| {
unsupported_config(check, project_root, config_dir).map(|config| (*check, config))
}) {
let canonical = check
.linter_config
.map(|(file, _)| format!("FLINT_CONFIG_DIR/{file}"))
.unwrap_or_else(|| "the flint-managed config".to_string());
eprintln!(
"flint: unsupported {name} config file found: {config}\n Flint only supports {canonical} for {name}. Move the config to the supported location or remove the alternate file.",
name = check.name
);
std::process::exit(1);
}

if args.verbose {
let names: Vec<&str> = active.iter().map(|c| c.name).collect();
if names.is_empty() {
Expand Down Expand Up @@ -566,6 +580,10 @@ fn baseline_check_names(

let changed = changed_rel_paths(file_list, project_root);
let previous_tools = registry::read_mise_tools_at_ref(project_root, merge_base);
if registry::flint_version_changed(&previous_tools, current_tools) {
return active.iter().map(|check| check.name.to_string()).collect();
}

let flint_config = config_rel_path(project_root, config_dir, "flint.toml");
let flint_config_changed = changed.contains(&flint_config);
let flint_toml =
Expand All @@ -581,14 +599,26 @@ fn baseline_check_names(
|| (matches!(check.kind, CheckKind::Special(_))
&& change.check_changed(check.name))
})
|| check.linter_config.is_some_and(|(file, _)| {
changed.contains(&config_rel_path(project_root, config_dir, file))
|| check.baseline_configs.iter().any(|config| {
changed.contains(&config_file_rel_path(project_root, config_dir, config))
})
})
.map(|check| check.name.to_string())
.collect()
}

fn unsupported_config(
check: &registry::Check,
project_root: &Path,
config_dir: &Path,
) -> Option<String> {
check
.unsupported_configs
.iter()
.find(|config| config_present(project_root, config_dir, config))
.map(|config| config_file_rel_path(project_root, config_dir, config))
}

struct FlintTomlChange {
current: toml::Value,
previous: toml::Value,
Expand Down Expand Up @@ -623,6 +653,30 @@ fn read_toml_file(path: &Path) -> toml::Value {
.unwrap_or(toml::Value::Table(Default::default()))
}

fn config_present(project_root: &Path, config_dir: &Path, config: &registry::ConfigFile) -> bool {
let path = config_file_abs_path(project_root, config_dir, config);
match config.presence {
registry::ConfigMatch::Exists => path.exists(),
registry::ConfigMatch::TomlSection(section) => {
toml_section(&read_toml_file(&path), section).is_some()
}
registry::ConfigMatch::IniSection(section) => ini_section_exists(&path, section),
}
}

fn ini_section_exists(path: &Path, section: &str) -> bool {
let Ok(content) = std::fs::read_to_string(path) else {
return false;
};
content.lines().any(|line| {
let trimmed = line.trim();
trimmed
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
.is_some_and(|name| name.trim() == section)
})
}

fn read_toml_at_ref(project_root: &Path, git_ref: &str, rel_path: &str) -> toml::Value {
let spec = format!("{git_ref}:{rel_path}");
std::process::Command::new("git")
Expand Down Expand Up @@ -668,6 +722,34 @@ fn config_rel_path(project_root: &Path, config_dir: &Path, file: &str) -> String
.unwrap_or_else(|_| normalize_path(&PathBuf::from(file)))
}

fn config_file_abs_path(
project_root: &Path,
config_dir: &Path,
config: &registry::ConfigFile,
) -> PathBuf {
match config.base {
registry::ConfigBase::ProjectRoot => project_root.join(config.path),
registry::ConfigBase::ConfigDir => {
if config_dir.is_absolute() {
config_dir.join(config.path)
} else {
project_root.join(config_dir).join(config.path)
}
}
}
}

fn config_file_rel_path(
project_root: &Path,
config_dir: &Path,
config: &registry::ConfigFile,
) -> String {
let path = config_file_abs_path(project_root, config_dir, config);
path.strip_prefix(project_root)
.map(normalize_path)
.unwrap_or_else(|_| normalize_path(&PathBuf::from(config.path)))
}

fn normalize_path(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
Expand Down
85 changes: 84 additions & 1 deletion src/registry/checks.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,69 @@
use super::types::{Check, SpecialKind};
use super::types::{Check, ConfigFile, SpecialKind};
use crate::linters::renovate_deps::RENOVATE_CONFIG_PATTERNS;

const TOOL_RUMDL: &[&str] = &["tool", "rumdl"];
const TOOL_CODESPELL: &[&str] = &["tool", "codespell"];
const TOOL_RUFF: &[&str] = &["tool", "ruff"];

const SHELLCHECK_BASELINE_CONFIGS: &[ConfigFile] = &[ConfigFile::config_dir(".shellcheckrc")];
const SHELLCHECK_UNSUPPORTED_CONFIGS: &[ConfigFile] = &[
ConfigFile::config_dir("shellcheckrc"),
ConfigFile::project("shellcheckrc"),
];
const RUMDL_BASELINE_CONFIGS: &[ConfigFile] = &[ConfigFile::config_dir(".rumdl.toml")];
const RUMDL_UNSUPPORTED_CONFIGS: &[ConfigFile] = &[
ConfigFile::config_dir("rumdl.toml"),
ConfigFile::project("rumdl.toml"),
ConfigFile::project(".config/rumdl.toml"),
ConfigFile::project_toml_section("pyproject.toml", TOOL_RUMDL),
];
const YAMLLINT_BASELINE_CONFIGS: &[ConfigFile] = &[ConfigFile::config_dir(".yamllint.yml")];
const YAMLLINT_UNSUPPORTED_CONFIGS: &[ConfigFile] = &[
ConfigFile::config_dir(".yamllint"),
ConfigFile::config_dir(".yamllint.yaml"),
ConfigFile::project(".yamllint"),
ConfigFile::project(".yamllint.yaml"),
];
const ACTIONLINT_BASELINE_CONFIGS: &[ConfigFile] = &[ConfigFile::config_dir("actionlint.yml")];
const ACTIONLINT_UNSUPPORTED_CONFIGS: &[ConfigFile] = &[
ConfigFile::config_dir("actionlint.yaml"),
ConfigFile::project(".github/actionlint.yaml"),
ConfigFile::project(".github/actionlint.yml"),
];
const HADOLINT_BASELINE_CONFIGS: &[ConfigFile] = &[ConfigFile::config_dir(".hadolint.yaml")];
const HADOLINT_UNSUPPORTED_CONFIGS: &[ConfigFile] = &[
ConfigFile::config_dir(".hadolint.yml"),
ConfigFile::project(".hadolint.yml"),
];
const CODESPELL_BASELINE_CONFIGS: &[ConfigFile] = &[ConfigFile::config_dir(".codespellrc")];
const CODESPELL_UNSUPPORTED_CONFIGS: &[ConfigFile] = &[
ConfigFile::project_ini_section("setup.cfg", "codespell"),
ConfigFile::project_toml_section("pyproject.toml", TOOL_CODESPELL),
];
const EDITORCONFIG_CHECKER_BASELINE_CONFIGS: &[ConfigFile] = &[
ConfigFile::config_dir(".editorconfig-checker.json"),
ConfigFile::project(".editorconfig"),
];
const EDITORCONFIG_CHECKER_UNSUPPORTED_CONFIGS: &[ConfigFile] = &[
ConfigFile::config_dir(".ecrc"),
ConfigFile::project(".ecrc"),
];
const GOLANGCI_LINT_BASELINE_CONFIGS: &[ConfigFile] = &[ConfigFile::config_dir(".golangci.yml")];
const GOLANGCI_LINT_UNSUPPORTED_CONFIGS: &[ConfigFile] = &[
ConfigFile::config_dir(".golangci.yaml"),
ConfigFile::config_dir(".golangci.toml"),
ConfigFile::config_dir(".golangci.json"),
ConfigFile::project(".golangci.yaml"),
ConfigFile::project(".golangci.toml"),
ConfigFile::project(".golangci.json"),
];
const RUFF_BASELINE_CONFIGS: &[ConfigFile] = &[ConfigFile::config_dir("ruff.toml")];
const RUFF_UNSUPPORTED_CONFIGS: &[ConfigFile] = &[
ConfigFile::config_dir(".ruff.toml"),
ConfigFile::project(".ruff.toml"),
ConfigFile::project_toml_section("pyproject.toml", TOOL_RUFF),
];

/// Built-in linter registry.
///
/// # Naming convention
Expand All @@ -20,6 +83,8 @@ fn check_shellcheck() -> Check {
&["*.sh", "*.bash", "*.bats"],
)
.linter_config(".shellcheckrc", "--rcfile")
.baseline_configs(SHELLCHECK_BASELINE_CONFIGS)
.unsupported_configs(SHELLCHECK_UNSUPPORTED_CONFIGS)
.desc("Lint shell scripts for common mistakes")
.style()
}
Expand All @@ -36,6 +101,8 @@ fn check_rumdl() -> Check {
Check::file("rumdl", "rumdl check {FILE}", &["*.md"])
.fix("rumdl check --fix {FILE}")
.linter_config(".rumdl.toml", "--config")
.baseline_configs(RUMDL_BASELINE_CONFIGS)
.unsupported_configs(RUMDL_UNSUPPORTED_CONFIGS)
.formatter()
.desc("Lint Markdown files for style and consistency")
.mise_tool("rumdl")
Expand All @@ -45,6 +112,8 @@ fn check_yaml_lint() -> Check {
Check::files("yaml-lint", "yaml-lint {FILES}", &["*.yml", "*.yaml"])
.fix("yaml-lint --fix {FILES}")
.linter_config(".yamllint.yml", "-c")
.baseline_configs(YAMLLINT_BASELINE_CONFIGS)
.unsupported_configs(YAMLLINT_UNSUPPORTED_CONFIGS)
.formatter()
.desc("Lint YAML files for style and consistency")
.mise_tool("cargo:yaml-lint")
Expand All @@ -57,6 +126,8 @@ fn check_actionlint() -> Check {
&[".github/workflows/*.yml", ".github/workflows/*.yaml"],
)
.linter_config("actionlint.yml", "-config-file")
.baseline_configs(ACTIONLINT_BASELINE_CONFIGS)
.unsupported_configs(ACTIONLINT_UNSUPPORTED_CONFIGS)
.desc("Lint GitHub Actions workflow files")
.style()
}
Expand All @@ -68,6 +139,8 @@ fn check_hadolint() -> Check {
&["Dockerfile", "Dockerfile.*", "*.dockerfile"],
)
.linter_config(".hadolint.yaml", "--config")
.baseline_configs(HADOLINT_BASELINE_CONFIGS)
.unsupported_configs(HADOLINT_UNSUPPORTED_CONFIGS)
.desc("Lint Dockerfiles")
.style()
}
Expand All @@ -82,6 +155,8 @@ fn check_codespell() -> Check {
Check::files("codespell", "codespell {FILES}", &["*"])
.fix("codespell --write-changes {FILES}")
.linter_config(".codespellrc", "--config")
.baseline_configs(CODESPELL_BASELINE_CONFIGS)
.unsupported_configs(CODESPELL_UNSUPPORTED_CONFIGS)
.desc("Check for common spelling mistakes")
.mise_tool("pipx:codespell")
}
Expand All @@ -95,6 +170,8 @@ fn check_editorconfig_checker() -> Check {
.mise_tool("editorconfig-checker")
.defer_to_formatters()
.linter_config(".editorconfig-checker.json", "-config")
.baseline_configs(EDITORCONFIG_CHECKER_BASELINE_CONFIGS)
.unsupported_configs(EDITORCONFIG_CHECKER_UNSUPPORTED_CONFIGS)
.desc("Check files comply with EditorConfig settings")
}

Expand All @@ -105,6 +182,8 @@ fn check_golangci_lint() -> Check {
&["*.go"],
)
.linter_config(".golangci.yml", "--config")
.baseline_configs(GOLANGCI_LINT_BASELINE_CONFIGS)
.unsupported_configs(GOLANGCI_LINT_UNSUPPORTED_CONFIGS)
.desc("Lint Go code; uses --new-from-rev to scope analysis to changed code")
.lang()
}
Expand All @@ -113,6 +192,8 @@ fn check_ruff() -> Check {
Check::file("ruff", "ruff check {FILE}", &["*.py"])
.fix("ruff check --fix {FILE}")
.linter_config("ruff.toml", "--config")
.baseline_configs(RUFF_BASELINE_CONFIGS)
.unsupported_configs(RUFF_UNSUPPORTED_CONFIGS)
.desc("Lint Python code")
.mise_tool("pipx:ruff")
.lang()
Expand All @@ -123,6 +204,8 @@ fn check_ruff_format() -> Check {
.bin("ruff")
.fix("ruff format {FILE}")
.linter_config("ruff.toml", "--config")
.baseline_configs(RUFF_BASELINE_CONFIGS)
.unsupported_configs(RUFF_UNSUPPORTED_CONFIGS)
.formatter()
.desc("Format Python code")
.mise_tool("pipx:ruff")
Expand Down
9 changes: 9 additions & 0 deletions src/registry/mise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ pub fn tool_version_changed(
previous.is_some() && current.is_some() && previous != current
}

pub fn flint_version_changed(
previous_tools: &HashMap<String, String>,
current_tools: &HashMap<String, String>,
) -> bool {
let previous = previous_tools.get("github:grafana/flint");
let current = current_tools.get("github:grafana/flint");
previous.is_some() && current.is_some() && previous != current
}

fn declared_tool_version<'a>(
check: &Check,
mise_tools: &'a HashMap<String, String>,
Expand Down
10 changes: 8 additions & 2 deletions src/registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ mod resolve;
mod types;

pub use checks::builtin;
pub use mise::{check_active, read_mise_tools, read_mise_tools_at_ref, tool_version_changed};
pub use mise::{
check_active, flint_version_changed, read_mise_tools, read_mise_tools_at_ref,
tool_version_changed,
};
pub use obsolete::{OBSOLETE_KEYS, find_obsolete_key, find_unsupported_key};
pub use resolve::binary_on_path;
pub use types::{Category, Check, CheckKind, FixBehavior, RunPolicy, Scope, SpecialKind};
pub use types::{
Category, Check, CheckKind, ConfigBase, ConfigFile, ConfigMatch, FixBehavior, RunPolicy, Scope,
SpecialKind,
};

/// Returns the set of `mise.toml` tool keys that name language runtimes/SDKs
/// (e.g. `rust`, `go`, `dotnet`). Derived from registry checks marked
Expand Down
Loading
Loading