use crate::file_utils::safe_read_file;
use crate::fs::{FileSystem, RealFileSystem};
use crate::schemas::mcp::DEFAULT_MCP_PROTOCOL_VERSION;
use rust_i18n::t;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
const MAX_FILE_PATTERNS: usize = 100;
mod builder;
mod rule_filter;
mod schema;
pub use builder::LintConfigBuilder;
pub use schema::{ConfigWarning, generate_schema};
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct ToolVersions {
#[serde(default)]
#[schemars(description = "Claude Code version for version-aware validation (e.g., \"1.0.0\")")]
pub claude_code: Option<String>,
#[serde(default)]
#[schemars(description = "Codex CLI version for version-aware validation (e.g., \"0.1.0\")")]
pub codex: Option<String>,
#[serde(default)]
#[schemars(description = "Cursor version for version-aware validation (e.g., \"0.45.0\")")]
pub cursor: Option<String>,
#[serde(default)]
#[schemars(
description = "GitHub Copilot version for version-aware validation (e.g., \"1.0.0\")"
)]
pub copilot: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct SpecRevisions {
#[serde(default)]
#[schemars(
description = "MCP protocol version for revision-specific validation (e.g., \"2025-11-25\", \"2024-11-05\")"
)]
pub mcp_protocol: Option<String>,
#[serde(default)]
#[schemars(description = "Agent Skills specification revision")]
pub agent_skills_spec: Option<String>,
#[serde(default)]
#[schemars(description = "AGENTS.md specification revision")]
pub agents_md_spec: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct FilesConfig {
#[serde(default)]
#[schemars(
description = "Glob patterns for files to validate as memory/instruction files (ClaudeMd rules)"
)]
pub include_as_memory: Vec<String>,
#[serde(default)]
#[schemars(
description = "Glob patterns for files to validate as generic markdown (XML, XP, REF rules)"
)]
pub include_as_generic: Vec<String>,
#[serde(default)]
#[schemars(description = "Glob patterns for files to exclude from validation")]
pub exclude: Vec<String>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ConfigError {
InvalidGlobPattern {
pattern: String,
error: String,
},
PathTraversal {
pattern: String,
},
AbsolutePathPattern {
pattern: String,
},
ValidationFailed(Vec<ConfigWarning>),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::InvalidGlobPattern { pattern, error } => {
write!(f, "invalid glob pattern '{}': {}", pattern, error)
}
ConfigError::PathTraversal { pattern } => {
write!(f, "path traversal in pattern '{}'", pattern)
}
ConfigError::AbsolutePathPattern { pattern } => {
write!(
f,
"absolute path in pattern '{}': use relative paths only",
pattern
)
}
ConfigError::ValidationFailed(warnings) => {
if warnings.is_empty() {
write!(f, "configuration validation failed with 0 warning(s)")
} else {
write!(
f,
"configuration validation failed with {} warning(s): {}",
warnings.len(),
warnings[0].message
)
}
}
}
}
}
impl std::error::Error for ConfigError {}
#[derive(Clone)]
struct RuntimeContext {
root_dir: Option<PathBuf>,
import_cache: Option<crate::parsers::ImportCache>,
fs: Arc<dyn FileSystem>,
}
impl Default for RuntimeContext {
fn default() -> Self {
Self {
root_dir: None,
import_cache: None,
fs: Arc::new(RealFileSystem),
}
}
}
impl std::fmt::Debug for RuntimeContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RuntimeContext")
.field("root_dir", &self.root_dir)
.field(
"import_cache",
&self.import_cache.as_ref().map(|_| "ImportCache(...)"),
)
.field("fs", &"Arc<dyn FileSystem>")
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub(in crate::config) struct ConfigData {
#[schemars(description = "Minimum severity level to report (Error, Warning, Info)")]
severity: SeverityLevel,
#[schemars(description = "Configuration for enabling/disabling validation rules by category")]
rules: RuleConfig,
#[schemars(
description = "Glob patterns for paths to exclude from validation (e.g., [\"node_modules/**\", \"dist/**\"])"
)]
exclude: Vec<String>,
#[schemars(
description = "Target tool for validation. In config files, use PascalCase enum names (e.g., ClaudeCode, Cursor, Codex, Kiro, Generic). Deprecated: use 'tools' array instead."
)]
target: TargetTool,
#[serde(default)]
#[schemars(
description = "Tools to validate for. Valid values: \"claude-code\", \"cursor\", \"codex\", \"kiro\", \"copilot\", \"github-copilot\", \"cline\", \"opencode\", \"gemini-cli\", \"amp\", \"roo-code\", \"windsurf\", \"generic\""
)]
tools: Vec<String>,
#[schemars(
description = "Expected MCP protocol version (deprecated: use spec_revisions.mcp_protocol instead)"
)]
mcp_protocol_version: Option<String>,
#[serde(default)]
#[schemars(description = "Pin specific tool versions for version-aware validation")]
tool_versions: ToolVersions,
#[serde(default)]
#[schemars(description = "Pin specific specification revisions for revision-aware validation")]
spec_revisions: SpecRevisions,
#[serde(default)]
#[schemars(
description = "File inclusion/exclusion configuration for non-standard agent files"
)]
files: FilesConfig,
#[serde(default)]
#[schemars(
description = "Output locale for translated messages (e.g., \"en\", \"es\", \"zh-CN\")"
)]
locale: Option<String>,
#[serde(default = "default_max_files")]
max_files_to_validate: Option<usize>,
}
impl Default for ConfigData {
fn default() -> Self {
Self {
severity: SeverityLevel::Warning,
rules: RuleConfig::default(),
exclude: vec![
"node_modules/**".to_string(),
".git/**".to_string(),
"target/**".to_string(),
],
target: TargetTool::Generic,
tools: Vec::new(),
mcp_protocol_version: None,
tool_versions: ToolVersions::default(),
spec_revisions: SpecRevisions::default(),
files: FilesConfig::default(),
locale: None,
max_files_to_validate: Some(DEFAULT_MAX_FILES),
}
}
}
#[derive(Clone)]
pub struct LintConfig {
pub(in crate::config) data: Arc<ConfigData>,
runtime: RuntimeContext,
}
impl std::fmt::Debug for LintConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LintConfig")
.field("severity", &self.data.severity)
.field("rules", &self.data.rules)
.field("exclude", &self.data.exclude)
.field("target", &self.data.target)
.field("tools", &self.data.tools)
.field("mcp_protocol_version", &self.data.mcp_protocol_version)
.field("tool_versions", &self.data.tool_versions)
.field("spec_revisions", &self.data.spec_revisions)
.field("files", &self.data.files)
.field("locale", &self.data.locale)
.field("max_files_to_validate", &self.data.max_files_to_validate)
.field("runtime", &self.runtime)
.finish()
}
}
impl Serialize for LintConfig {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.data.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for LintConfig {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let data = ConfigData::deserialize(deserializer)?;
Ok(Self {
data: Arc::new(data),
runtime: RuntimeContext::default(),
})
}
}
impl JsonSchema for LintConfig {
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("LintConfig")
}
fn schema_id() -> std::borrow::Cow<'static, str> {
ConfigData::schema_id()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
ConfigData::json_schema(generator)
}
}
pub const DEFAULT_MAX_FILES: usize = 10_000;
fn default_max_files() -> Option<usize> {
Some(DEFAULT_MAX_FILES)
}
fn has_path_traversal(normalized: &str) -> bool {
normalized == ".."
|| normalized.starts_with("../")
|| normalized.contains("/../")
|| normalized.ends_with("/..")
}
impl Default for LintConfig {
fn default() -> Self {
Self {
data: Arc::new(ConfigData::default()),
runtime: RuntimeContext::default(),
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[schemars(description = "Severity level for filtering diagnostics")]
pub enum SeverityLevel {
Error,
Warning,
Info,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[schemars(description = "Configuration for enabling/disabling validation rules by category")]
pub struct RuleConfig {
#[serde(default = "default_true")]
#[schemars(description = "Enable Agent Skills validation rules (AS-*, CC-SK-*)")]
pub skills: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Claude Code hooks validation rules (CC-HK-*)")]
pub hooks: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Claude Code agents validation rules (CC-AG-*)")]
pub agents: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Claude Code memory validation rules (CC-MEM-*)")]
pub memory: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Claude Code output-style validation rules (CC-OS-*)")]
pub output_styles: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Claude Code plugins validation rules (CC-PL-*)")]
pub plugins: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable XML tag balance validation rules (XML-*)")]
pub xml: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Model Context Protocol validation rules (MCP-*)")]
pub mcp: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable import reference validation rules (REF-*)")]
pub imports: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable cross-platform validation rules (XP-*)")]
pub cross_platform: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable AGENTS.md validation rules (AGM-*)")]
pub agents_md: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable GitHub Copilot validation rules (COP-*)")]
pub copilot: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Cursor project rules validation (CUR-*)")]
pub cursor: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Cline rules validation (CLN-*)")]
pub cline: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable OpenCode validation rules (OC-*)")]
pub opencode: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Gemini CLI validation rules (GM-*)")]
pub gemini_md: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Codex CLI validation rules (CDX-*)")]
pub codex: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Roo Code validation rules (ROO-*)")]
pub roo_code: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Windsurf validation rules (WS-*)")]
pub windsurf: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Kiro steering validation rules (KIRO-*)")]
pub kiro_steering: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Kiro agent validation rules (KR-AG-*)")]
pub kiro_agents: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable Amp checks validation rules (AMP-*)")]
pub amp_checks: bool,
#[serde(default = "default_true")]
#[schemars(description = "Enable prompt engineering validation rules (PE-*)")]
pub prompt_engineering: bool,
#[serde(default = "default_true")]
#[schemars(description = "Detect generic placeholder instructions in CLAUDE.md")]
pub generic_instructions: bool,
#[serde(default = "default_true")]
#[schemars(description = "Validate YAML frontmatter in skill files")]
pub frontmatter_validation: bool,
#[serde(default = "default_true")]
#[schemars(description = "Check XML tag balance (legacy: use 'xml' instead)")]
pub xml_balance: bool,
#[serde(default = "default_true")]
#[schemars(description = "Validate @import references (legacy: use 'imports' instead)")]
pub import_references: bool,
#[serde(default)]
#[schemars(
description = "List of rule IDs to explicitly disable (e.g., [\"CC-AG-001\", \"AS-005\"])"
)]
pub disabled_rules: Vec<String>,
#[serde(default)]
#[schemars(
description = "List of validator names to disable (e.g., [\"XmlValidator\", \"PromptValidator\"])"
)]
pub disabled_validators: Vec<String>,
}
impl Default for RuleConfig {
fn default() -> Self {
Self {
skills: true,
hooks: true,
agents: true,
memory: true,
output_styles: true,
plugins: true,
xml: true,
mcp: true,
imports: true,
cross_platform: true,
agents_md: true,
copilot: true,
cursor: true,
cline: true,
opencode: true,
gemini_md: true,
codex: true,
roo_code: true,
windsurf: true,
kiro_steering: true,
kiro_agents: true,
amp_checks: true,
prompt_engineering: true,
generic_instructions: true,
frontmatter_validation: true,
xml_balance: true,
import_references: true,
disabled_rules: Vec::new(),
disabled_validators: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[schemars(
description = "Target tool for validation (deprecated: use 'tools' array for multi-tool support)"
)]
pub enum TargetTool {
Generic,
ClaudeCode,
Cursor,
Codex,
Kiro,
}
impl LintConfig {
pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
let content = safe_read_file(path.as_ref())?;
let config = toml::from_str(&content)?;
Ok(config)
}
pub fn load_or_default(path: Option<&PathBuf>) -> (Self, Option<String>) {
match path {
Some(p) => match Self::load(p) {
Ok(config) => (config, None),
Err(e) => {
let warning = t!(
"core.config.load_warning",
path = p.display().to_string(),
error = e.to_string()
);
(Self::default(), Some(warning.to_string()))
}
},
None => (Self::default(), None),
}
}
#[inline]
pub fn root_dir(&self) -> Option<&PathBuf> {
self.runtime.root_dir.as_ref()
}
#[inline]
pub fn get_root_dir(&self) -> Option<&PathBuf> {
self.root_dir()
}
pub fn set_root_dir(&mut self, root_dir: PathBuf) {
self.runtime.root_dir = Some(root_dir);
}
pub fn set_import_cache(&mut self, cache: crate::parsers::ImportCache) {
self.runtime.import_cache = Some(cache);
}
#[inline]
pub fn import_cache(&self) -> Option<&crate::parsers::ImportCache> {
self.runtime.import_cache.as_ref()
}
#[inline]
pub fn get_import_cache(&self) -> Option<&crate::parsers::ImportCache> {
self.import_cache()
}
pub fn fs(&self) -> &Arc<dyn FileSystem> {
&self.runtime.fs
}
pub fn set_fs(&mut self, fs: Arc<dyn FileSystem>) {
self.runtime.fs = fs;
}
#[inline]
pub fn severity(&self) -> SeverityLevel {
self.data.severity
}
#[inline]
pub fn rules(&self) -> &RuleConfig {
&self.data.rules
}
#[inline]
pub fn exclude(&self) -> &[String] {
&self.data.exclude
}
#[inline]
pub fn target(&self) -> TargetTool {
self.data.target
}
#[inline]
pub fn tools(&self) -> &[String] {
&self.data.tools
}
#[inline]
pub fn tool_versions(&self) -> &ToolVersions {
&self.data.tool_versions
}
#[inline]
pub fn spec_revisions(&self) -> &SpecRevisions {
&self.data.spec_revisions
}
#[inline]
pub fn files_config(&self) -> &FilesConfig {
&self.data.files
}
#[inline]
pub fn locale(&self) -> Option<&str> {
self.data.locale.as_deref()
}
#[inline]
pub fn max_files_to_validate(&self) -> Option<usize> {
self.data.max_files_to_validate
}
#[inline]
pub fn mcp_protocol_version_raw(&self) -> Option<&str> {
self.data.mcp_protocol_version.as_deref()
}
pub fn set_severity(&mut self, severity: SeverityLevel) {
Arc::make_mut(&mut self.data).severity = severity;
}
pub fn set_target(&mut self, target: TargetTool) {
Arc::make_mut(&mut self.data).target = target;
}
pub fn set_tools(&mut self, tools: Vec<String>) {
Arc::make_mut(&mut self.data).tools = tools;
}
pub fn tools_mut(&mut self) -> &mut Vec<String> {
&mut Arc::make_mut(&mut self.data).tools
}
pub fn set_exclude(&mut self, exclude: Vec<String>) {
Arc::make_mut(&mut self.data).exclude = exclude;
}
pub fn set_locale(&mut self, locale: Option<String>) {
Arc::make_mut(&mut self.data).locale = locale;
}
pub fn set_max_files_to_validate(&mut self, max: Option<usize>) {
Arc::make_mut(&mut self.data).max_files_to_validate = max;
}
pub fn set_mcp_protocol_version(&mut self, version: Option<String>) {
Arc::make_mut(&mut self.data).mcp_protocol_version = version;
}
pub fn rules_mut(&mut self) -> &mut RuleConfig {
&mut Arc::make_mut(&mut self.data).rules
}
pub fn tool_versions_mut(&mut self) -> &mut ToolVersions {
&mut Arc::make_mut(&mut self.data).tool_versions
}
pub fn spec_revisions_mut(&mut self) -> &mut SpecRevisions {
&mut Arc::make_mut(&mut self.data).spec_revisions
}
pub fn files_mut(&mut self) -> &mut FilesConfig {
&mut Arc::make_mut(&mut self.data).files
}
#[inline]
pub fn get_mcp_protocol_version(&self) -> &str {
self.data
.spec_revisions
.mcp_protocol
.as_deref()
.or(self.data.mcp_protocol_version.as_deref())
.unwrap_or(DEFAULT_MCP_PROTOCOL_VERSION)
}
#[inline]
pub fn is_mcp_revision_pinned(&self) -> bool {
self.data.spec_revisions.mcp_protocol.is_some() || self.data.mcp_protocol_version.is_some()
}
#[inline]
pub fn is_claude_code_version_pinned(&self) -> bool {
self.data.tool_versions.claude_code.is_some()
}
#[inline]
pub fn get_claude_code_version(&self) -> Option<&str> {
self.data.tool_versions.claude_code.as_deref()
}
}
#[cfg(test)]
mod tests;