agnix_core/config.rs
1//! Linter configuration
2
3use crate::file_utils::safe_read_file;
4use crate::fs::{FileSystem, RealFileSystem};
5use crate::schemas::mcp::DEFAULT_MCP_PROTOCOL_VERSION;
6use rust_i18n::t;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12/// Maximum number of file patterns per list (include_as_memory, include_as_generic, exclude).
13/// Exceeding this limit produces a configuration warning.
14const MAX_FILE_PATTERNS: usize = 100;
15
16mod builder;
17mod rule_filter;
18mod schema;
19
20pub use builder::LintConfigBuilder;
21pub use schema::{ConfigWarning, generate_schema};
22/// Tool version pinning for version-aware validation
23///
24/// When tool versions are pinned, validators can apply version-specific
25/// behavior instead of using default assumptions. When not pinned,
26/// validators will use sensible defaults and add assumption notes.
27#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
28pub struct ToolVersions {
29 /// Claude Code version (e.g., "1.0.0")
30 #[serde(default)]
31 #[schemars(description = "Claude Code version for version-aware validation (e.g., \"1.0.0\")")]
32 pub claude_code: Option<String>,
33
34 /// Codex CLI version (e.g., "0.1.0")
35 #[serde(default)]
36 #[schemars(description = "Codex CLI version for version-aware validation (e.g., \"0.1.0\")")]
37 pub codex: Option<String>,
38
39 /// Cursor version (e.g., "0.45.0")
40 #[serde(default)]
41 #[schemars(description = "Cursor version for version-aware validation (e.g., \"0.45.0\")")]
42 pub cursor: Option<String>,
43
44 /// GitHub Copilot version (e.g., "1.0.0")
45 #[serde(default)]
46 #[schemars(
47 description = "GitHub Copilot version for version-aware validation (e.g., \"1.0.0\")"
48 )]
49 pub copilot: Option<String>,
50}
51
52/// Specification revision pinning for version-aware validation
53///
54/// When spec revisions are pinned, validators can apply revision-specific
55/// rules. When not pinned, validators use the latest known revision.
56#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
57pub struct SpecRevisions {
58 /// MCP protocol version (e.g., "2025-11-25", "2024-11-05")
59 #[serde(default)]
60 #[schemars(
61 description = "MCP protocol version for revision-specific validation (e.g., \"2025-11-25\", \"2024-11-05\")"
62 )]
63 pub mcp_protocol: Option<String>,
64
65 /// Agent Skills specification revision
66 #[serde(default)]
67 #[schemars(description = "Agent Skills specification revision")]
68 pub agent_skills_spec: Option<String>,
69
70 /// AGENTS.md specification revision
71 #[serde(default)]
72 #[schemars(description = "AGENTS.md specification revision")]
73 pub agents_md_spec: Option<String>,
74}
75
76/// File inclusion/exclusion configuration for non-standard agent files.
77///
78/// By default, agnix only validates files it recognizes (CLAUDE.md, SKILL.md, etc.).
79/// Use this section to include additional files in validation or exclude files
80/// that would otherwise be validated.
81///
82/// Patterns use glob syntax (e.g., `"docs/ai-rules/*.md"`).
83/// Paths are matched relative to the project root.
84///
85/// Priority: `exclude` > `include_as_memory` > `include_as_generic` > built-in detection.
86#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
87pub struct FilesConfig {
88 /// Glob patterns for files to validate as memory/instruction files (ClaudeMd rules).
89 ///
90 /// Files matching these patterns will be treated as CLAUDE.md-like files,
91 /// receiving the full set of memory/instruction validation rules.
92 #[serde(default)]
93 #[schemars(
94 description = "Glob patterns for files to validate as memory/instruction files (ClaudeMd rules)"
95 )]
96 pub include_as_memory: Vec<String>,
97
98 /// Glob patterns for files to validate as generic markdown (XML, XP, REF rules).
99 ///
100 /// Files matching these patterns will receive generic markdown validation
101 /// (XML balance, import references, cross-platform checks).
102 #[serde(default)]
103 #[schemars(
104 description = "Glob patterns for files to validate as generic markdown (XML, XP, REF rules)"
105 )]
106 pub include_as_generic: Vec<String>,
107
108 /// Glob patterns for files to exclude from validation.
109 ///
110 /// Files matching these patterns will be skipped entirely, even if they
111 /// would otherwise be recognized by built-in detection.
112 #[serde(default)]
113 #[schemars(description = "Glob patterns for files to exclude from validation")]
114 pub exclude: Vec<String>,
115}
116
117// =============================================================================
118// Internal Composition Types (Facade Pattern)
119// =============================================================================
120//
121// LintConfig uses internal composition to separate concerns while maintaining
122// a stable public API. These types are private implementation details:
123//
124// - RuntimeContext: Groups non-serialized runtime state (root_dir, import_cache, fs)
125// - DefaultRuleFilter: Encapsulates rule filtering logic (~100 lines)
126//
127// This pattern provides:
128// 1. Better code organization without breaking changes
129// 2. Easier testing of individual components
130// 3. Clear separation between serialized config and runtime state
131// =============================================================================
132
133/// Errors that can occur when building or validating a `LintConfig`.
134///
135/// These are hard errors (not warnings) that indicate the configuration
136/// cannot be used as-is. For soft issues, see [`ConfigWarning`].
137///
138/// This enum is `#[non_exhaustive]`: match with a wildcard arm to handle
139/// future variants without breaking changes.
140#[derive(Debug, Clone)]
141#[non_exhaustive]
142pub enum ConfigError {
143 /// A glob pattern in the configuration is syntactically invalid.
144 InvalidGlobPattern {
145 /// The invalid glob pattern string.
146 pattern: String,
147 /// Description of the parse error.
148 error: String,
149 },
150 /// A glob pattern attempts path traversal (e.g. `../escape`).
151 PathTraversal {
152 /// The pattern containing path traversal.
153 pattern: String,
154 },
155 /// A glob pattern uses an absolute path (e.g. `/etc/passwd` or `C:/Windows/**`).
156 AbsolutePathPattern {
157 /// The absolute-path pattern.
158 pattern: String,
159 },
160 /// Validation produced warnings that were promoted to errors.
161 ValidationFailed(Vec<ConfigWarning>),
162}
163
164impl std::fmt::Display for ConfigError {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 match self {
167 ConfigError::InvalidGlobPattern { pattern, error } => {
168 write!(f, "invalid glob pattern '{}': {}", pattern, error)
169 }
170 ConfigError::PathTraversal { pattern } => {
171 write!(f, "path traversal in pattern '{}'", pattern)
172 }
173 ConfigError::AbsolutePathPattern { pattern } => {
174 write!(
175 f,
176 "absolute path in pattern '{}': use relative paths only",
177 pattern
178 )
179 }
180 ConfigError::ValidationFailed(warnings) => {
181 if warnings.is_empty() {
182 write!(f, "configuration validation failed with 0 warning(s)")
183 } else {
184 write!(
185 f,
186 "configuration validation failed with {} warning(s): {}",
187 warnings.len(),
188 warnings[0].message
189 )
190 }
191 }
192 }
193 }
194}
195
196impl std::error::Error for ConfigError {}
197
198/// Runtime context for validation operations (not serialized).
199///
200/// Groups non-serialized state that is set up at runtime and shared during
201/// validation. This includes the project root, import cache, and filesystem
202/// abstraction.
203///
204/// # Thread Safety
205///
206/// `RuntimeContext` is `Send + Sync` because:
207/// - `PathBuf` and `Option<T>` are `Send + Sync`
208/// - `ImportCache` uses interior mutability with thread-safe types
209/// - `Arc<dyn FileSystem>` shares the filesystem without deep-cloning
210///
211/// # Clone Behavior
212///
213/// When cloned, the `Arc<dyn FileSystem>` is shared (not deep-cloned),
214/// maintaining the same filesystem instance across clones.
215#[derive(Clone)]
216struct RuntimeContext {
217 /// Project root directory for validation.
218 ///
219 /// When set, validators can use this to resolve relative paths and
220 /// detect project-escape attempts in import validation.
221 root_dir: Option<PathBuf>,
222
223 /// Shared import cache for project-level validation.
224 ///
225 /// When set, validators can use this cache to share parsed import data
226 /// across files, avoiding redundant parsing during import chain traversal.
227 import_cache: Option<crate::parsers::ImportCache>,
228
229 /// File system abstraction for testability.
230 ///
231 /// Validators use this to perform file system operations. Defaults to
232 /// `RealFileSystem` which delegates to `std::fs` and `file_utils`.
233 fs: Arc<dyn FileSystem>,
234}
235
236impl Default for RuntimeContext {
237 fn default() -> Self {
238 Self {
239 root_dir: None,
240 import_cache: None,
241 fs: Arc::new(RealFileSystem),
242 }
243 }
244}
245
246impl std::fmt::Debug for RuntimeContext {
247 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248 f.debug_struct("RuntimeContext")
249 .field("root_dir", &self.root_dir)
250 .field(
251 "import_cache",
252 &self.import_cache.as_ref().map(|_| "ImportCache(...)"),
253 )
254 .field("fs", &"Arc<dyn FileSystem>")
255 .finish()
256 }
257}
258
259/// Shared, immutable configuration data wrapped in `Arc` for cheap cloning.
260///
261/// All serializable fields of `LintConfig` live here. When `LintConfig` is
262/// cloned (e.g., in `validate_project` / `validate_project_with_registry`),
263/// only the `Arc` refcount is bumped - no heap-allocated `Vec<String>` or
264/// nested structs are deep-copied. Mutation through setters uses
265/// `Arc::make_mut` for copy-on-write semantics.
266#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
267#[serde(default)]
268pub(in crate::config) struct ConfigData {
269 /// Severity level threshold
270 #[schemars(description = "Minimum severity level to report (Error, Warning, Info)")]
271 severity: SeverityLevel,
272
273 /// Rules to enable/disable
274 #[schemars(description = "Configuration for enabling/disabling validation rules by category")]
275 rules: RuleConfig,
276
277 /// Paths to exclude
278 #[schemars(
279 description = "Glob patterns for paths to exclude from validation (e.g., [\"node_modules/**\", \"dist/**\"])"
280 )]
281 exclude: Vec<String>,
282
283 /// Target tool for validation.
284 /// In configuration files, use PascalCase enum names
285 /// (`ClaudeCode`, `Cursor`, `Codex`, `Kiro`, `Generic`).
286 /// Deprecated: Use `tools` array instead for multi-tool support.
287 #[schemars(
288 description = "Target tool for validation. In config files, use PascalCase enum names (e.g., ClaudeCode, Cursor, Codex, Kiro, Generic). Deprecated: use 'tools' array instead."
289 )]
290 target: TargetTool,
291
292 /// Tools to validate for (e.g., ["claude-code", "cursor"])
293 /// When specified, agnix automatically enables rules for these tools
294 /// and disables rules for tools not in the list.
295 /// Valid values: "claude-code", "cursor", "codex", "kiro", "copilot",
296 /// "github-copilot", "cline", "opencode", "gemini-cli", "amp",
297 /// "roo-code", "windsurf", "generic"
298 #[serde(default)]
299 #[schemars(
300 description = "Tools to validate for. Valid values: \"claude-code\", \"cursor\", \"codex\", \"kiro\", \"copilot\", \"github-copilot\", \"cline\", \"opencode\", \"gemini-cli\", \"amp\", \"roo-code\", \"windsurf\", \"generic\""
301 )]
302 tools: Vec<String>,
303
304 /// Expected MCP protocol version for validation (MCP-008)
305 /// Deprecated: Use spec_revisions.mcp_protocol instead
306 #[schemars(
307 description = "Expected MCP protocol version (deprecated: use spec_revisions.mcp_protocol instead)"
308 )]
309 mcp_protocol_version: Option<String>,
310
311 /// Tool version pinning for version-aware validation
312 #[serde(default)]
313 #[schemars(description = "Pin specific tool versions for version-aware validation")]
314 tool_versions: ToolVersions,
315
316 /// Specification revision pinning for version-aware validation
317 #[serde(default)]
318 #[schemars(description = "Pin specific specification revisions for revision-aware validation")]
319 spec_revisions: SpecRevisions,
320
321 /// File inclusion/exclusion configuration for non-standard agent files
322 #[serde(default)]
323 #[schemars(
324 description = "File inclusion/exclusion configuration for non-standard agent files"
325 )]
326 files: FilesConfig,
327
328 /// Output locale for translated messages (e.g., "en", "es", "zh-CN").
329 /// When not set, the CLI locale detection is used.
330 #[serde(default)]
331 #[schemars(
332 description = "Output locale for translated messages (e.g., \"en\", \"es\", \"zh-CN\")"
333 )]
334 locale: Option<String>,
335
336 /// Maximum number of files to validate before stopping.
337 ///
338 /// This is a security feature to prevent DoS attacks via projects with
339 /// millions of small files. When the limit is reached, validation stops
340 /// with a `TooManyFiles` error.
341 ///
342 /// Default: 10,000 files. Set to `None` to disable the limit (not recommended).
343 #[serde(default = "default_max_files")]
344 max_files_to_validate: Option<usize>,
345}
346
347impl Default for ConfigData {
348 fn default() -> Self {
349 Self {
350 severity: SeverityLevel::Warning,
351 rules: RuleConfig::default(),
352 exclude: vec![
353 "node_modules/**".to_string(),
354 ".git/**".to_string(),
355 "target/**".to_string(),
356 ],
357 target: TargetTool::Generic,
358 tools: Vec::new(),
359 mcp_protocol_version: None,
360 tool_versions: ToolVersions::default(),
361 spec_revisions: SpecRevisions::default(),
362 files: FilesConfig::default(),
363 locale: None,
364 max_files_to_validate: Some(DEFAULT_MAX_FILES),
365 }
366 }
367}
368
369/// Configuration for the linter
370///
371/// # Cheap Cloning via `Arc<ConfigData>`
372///
373/// All serializable fields are stored in a shared `Arc<ConfigData>`.
374/// Cloning a `LintConfig` bumps the `Arc` refcount and shallow-copies the
375/// lightweight `RuntimeContext` - no heap-allocated `Vec<String>` or nested
376/// structs are deep-copied. Setters use `Arc::make_mut` for copy-on-write
377/// semantics, so mutations only allocate when the `Arc` is shared.
378#[derive(Clone)]
379pub struct LintConfig {
380 /// Shared serializable configuration data.
381 ///
382 /// Accessible within the config module for direct field access in
383 /// submodules (rule_filter, schema, builder, tests).
384 pub(in crate::config) data: Arc<ConfigData>,
385
386 /// Internal runtime context for validation operations (not serialized).
387 ///
388 /// Groups the filesystem abstraction, project root directory, and import
389 /// cache. These are non-serialized runtime state set up before validation.
390 runtime: RuntimeContext,
391}
392
393// ---------------------------------------------------------------------------
394// Serde, Debug, and JsonSchema implementations for LintConfig
395// ---------------------------------------------------------------------------
396//
397// Because LintConfig wraps its serializable fields in Arc<ConfigData>, we
398// implement Serialize/Deserialize/Debug/JsonSchema manually so that the
399// external representation is flat (identical to the old struct layout).
400// ---------------------------------------------------------------------------
401
402impl std::fmt::Debug for LintConfig {
403 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404 f.debug_struct("LintConfig")
405 .field("severity", &self.data.severity)
406 .field("rules", &self.data.rules)
407 .field("exclude", &self.data.exclude)
408 .field("target", &self.data.target)
409 .field("tools", &self.data.tools)
410 .field("mcp_protocol_version", &self.data.mcp_protocol_version)
411 .field("tool_versions", &self.data.tool_versions)
412 .field("spec_revisions", &self.data.spec_revisions)
413 .field("files", &self.data.files)
414 .field("locale", &self.data.locale)
415 .field("max_files_to_validate", &self.data.max_files_to_validate)
416 .field("runtime", &self.runtime)
417 .finish()
418 }
419}
420
421impl Serialize for LintConfig {
422 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
423 // Delegate to ConfigData - produces the same flat fields as before.
424 self.data.serialize(serializer)
425 }
426}
427
428impl<'de> Deserialize<'de> for LintConfig {
429 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
430 let data = ConfigData::deserialize(deserializer)?;
431 Ok(Self {
432 data: Arc::new(data),
433 runtime: RuntimeContext::default(),
434 })
435 }
436}
437
438impl JsonSchema for LintConfig {
439 fn schema_name() -> std::borrow::Cow<'static, str> {
440 std::borrow::Cow::Borrowed("LintConfig")
441 }
442
443 fn schema_id() -> std::borrow::Cow<'static, str> {
444 // Match ConfigData's schema_id so the generator treats them as the same
445 // schema and avoids registering two distinct definitions.
446 ConfigData::schema_id()
447 }
448
449 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
450 // Delegate to ConfigData so the schema is identical to the old flat layout.
451 ConfigData::json_schema(generator)
452 }
453}
454
455/// Default maximum files to validate (security limit)
456///
457/// **Design Decision**: 10,000 files was chosen as a balance between:
458/// - Large enough for realistic projects (Linux kernel has ~70k files, but most are not validated)
459/// - Small enough to prevent DoS from projects with millions of tiny files
460/// - Completes validation in reasonable time (seconds to low minutes on typical hardware)
461/// - Atomic counter with SeqCst ordering provides thread-safe counting during parallel validation
462///
463/// Users can override with `--max-files N` or disable with `--max-files 0` (not recommended).
464/// Set to `None` to disable the limit entirely (use with caution).
465pub const DEFAULT_MAX_FILES: usize = 10_000;
466
467/// Helper function for serde default
468fn default_max_files() -> Option<usize> {
469 Some(DEFAULT_MAX_FILES)
470}
471
472/// Check if a normalized (forward-slash) path pattern contains path traversal.
473///
474/// Catches `../`, `..` at the start, `/..` at the end, and standalone `..`.
475fn has_path_traversal(normalized: &str) -> bool {
476 normalized == ".."
477 || normalized.starts_with("../")
478 || normalized.contains("/../")
479 || normalized.ends_with("/..")
480}
481
482impl Default for LintConfig {
483 fn default() -> Self {
484 Self {
485 data: Arc::new(ConfigData::default()),
486 runtime: RuntimeContext::default(),
487 }
488 }
489}
490
491#[derive(
492 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
493)]
494#[schemars(description = "Severity level for filtering diagnostics")]
495pub enum SeverityLevel {
496 /// Only show errors
497 Error,
498 /// Show errors and warnings
499 Warning,
500 /// Show all diagnostics including info
501 Info,
502}
503
504/// Helper function for serde default
505fn default_true() -> bool {
506 true
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
510#[schemars(description = "Configuration for enabling/disabling validation rules by category")]
511pub struct RuleConfig {
512 /// Enable skills validation (AS-*, CC-SK-*)
513 #[serde(default = "default_true")]
514 #[schemars(description = "Enable Agent Skills validation rules (AS-*, CC-SK-*)")]
515 pub skills: bool,
516
517 /// Enable hooks validation (CC-HK-*)
518 #[serde(default = "default_true")]
519 #[schemars(description = "Enable Claude Code hooks validation rules (CC-HK-*)")]
520 pub hooks: bool,
521
522 /// Enable agents validation (CC-AG-*)
523 #[serde(default = "default_true")]
524 #[schemars(description = "Enable Claude Code agents validation rules (CC-AG-*)")]
525 pub agents: bool,
526
527 /// Enable memory validation (CC-MEM-*)
528 #[serde(default = "default_true")]
529 #[schemars(description = "Enable Claude Code memory validation rules (CC-MEM-*)")]
530 pub memory: bool,
531
532 /// Enable plugins validation (CC-PL-*)
533 #[serde(default = "default_true")]
534 #[schemars(description = "Enable Claude Code plugins validation rules (CC-PL-*)")]
535 pub plugins: bool,
536
537 /// Enable XML balance checking (XML-*)
538 #[serde(default = "default_true")]
539 #[schemars(description = "Enable XML tag balance validation rules (XML-*)")]
540 pub xml: bool,
541
542 /// Enable MCP validation (MCP-*)
543 #[serde(default = "default_true")]
544 #[schemars(description = "Enable Model Context Protocol validation rules (MCP-*)")]
545 pub mcp: bool,
546
547 /// Enable import reference validation (REF-*)
548 #[serde(default = "default_true")]
549 #[schemars(description = "Enable import reference validation rules (REF-*)")]
550 pub imports: bool,
551
552 /// Enable cross-platform validation (XP-*)
553 #[serde(default = "default_true")]
554 #[schemars(description = "Enable cross-platform validation rules (XP-*)")]
555 pub cross_platform: bool,
556
557 /// Enable AGENTS.md validation (AGM-*)
558 #[serde(default = "default_true")]
559 #[schemars(description = "Enable AGENTS.md validation rules (AGM-*)")]
560 pub agents_md: bool,
561
562 /// Enable GitHub Copilot validation (COP-*)
563 #[serde(default = "default_true")]
564 #[schemars(description = "Enable GitHub Copilot validation rules (COP-*)")]
565 pub copilot: bool,
566
567 /// Enable Cursor project rules validation (CUR-*)
568 #[serde(default = "default_true")]
569 #[schemars(description = "Enable Cursor project rules validation (CUR-*)")]
570 pub cursor: bool,
571
572 /// Enable Cline rules validation (CLN-*)
573 #[serde(default = "default_true")]
574 #[schemars(description = "Enable Cline rules validation (CLN-*)")]
575 pub cline: bool,
576
577 /// Enable OpenCode validation (OC-*)
578 #[serde(default = "default_true")]
579 #[schemars(description = "Enable OpenCode validation rules (OC-*)")]
580 pub opencode: bool,
581
582 /// Enable Gemini CLI validation (GM-*)
583 #[serde(default = "default_true")]
584 #[schemars(description = "Enable Gemini CLI validation rules (GM-*)")]
585 pub gemini_md: bool,
586
587 /// Enable Codex CLI validation (CDX-*)
588 #[serde(default = "default_true")]
589 #[schemars(description = "Enable Codex CLI validation rules (CDX-*)")]
590 pub codex: bool,
591
592 /// Enable Roo Code validation (ROO-*)
593 #[serde(default = "default_true")]
594 #[schemars(description = "Enable Roo Code validation rules (ROO-*)")]
595 pub roo_code: bool,
596
597 /// Enable Windsurf validation (WS-*)
598 #[serde(default = "default_true")]
599 #[schemars(description = "Enable Windsurf validation rules (WS-*)")]
600 pub windsurf: bool,
601
602 /// Enable Kiro steering validation (KIRO-*)
603 #[serde(default = "default_true")]
604 #[schemars(description = "Enable Kiro steering validation rules (KIRO-*)")]
605 pub kiro_steering: bool,
606
607 /// Enable Kiro agent validation (KR-AG-*)
608 #[serde(default = "default_true")]
609 #[schemars(description = "Enable Kiro agent validation rules (KR-AG-*)")]
610 pub kiro_agents: bool,
611
612 /// Enable Amp checks validation (AMP-*)
613 #[serde(default = "default_true")]
614 #[schemars(description = "Enable Amp checks validation rules (AMP-*)")]
615 pub amp_checks: bool,
616
617 /// Enable prompt engineering validation (PE-*)
618 #[serde(default = "default_true")]
619 #[schemars(description = "Enable prompt engineering validation rules (PE-*)")]
620 pub prompt_engineering: bool,
621
622 /// Detect generic instructions in CLAUDE.md
623 #[serde(default = "default_true")]
624 #[schemars(description = "Detect generic placeholder instructions in CLAUDE.md")]
625 pub generic_instructions: bool,
626
627 /// Validate YAML frontmatter
628 #[serde(default = "default_true")]
629 #[schemars(description = "Validate YAML frontmatter in skill files")]
630 pub frontmatter_validation: bool,
631
632 /// Check XML tag balance (legacy - use xml instead)
633 #[serde(default = "default_true")]
634 #[schemars(description = "Check XML tag balance (legacy: use 'xml' instead)")]
635 pub xml_balance: bool,
636
637 /// Validate @import references (legacy - use imports instead)
638 #[serde(default = "default_true")]
639 #[schemars(description = "Validate @import references (legacy: use 'imports' instead)")]
640 pub import_references: bool,
641
642 /// Explicitly disabled rules by ID (e.g., ["CC-AG-001", "AS-005"])
643 #[serde(default)]
644 #[schemars(
645 description = "List of rule IDs to explicitly disable (e.g., [\"CC-AG-001\", \"AS-005\"])"
646 )]
647 pub disabled_rules: Vec<String>,
648
649 /// Explicitly disabled validators by name (e.g., ["XmlValidator", "PromptValidator"])
650 #[serde(default)]
651 #[schemars(
652 description = "List of validator names to disable (e.g., [\"XmlValidator\", \"PromptValidator\"])"
653 )]
654 pub disabled_validators: Vec<String>,
655}
656
657impl Default for RuleConfig {
658 fn default() -> Self {
659 Self {
660 skills: true,
661 hooks: true,
662 agents: true,
663 memory: true,
664 plugins: true,
665 xml: true,
666 mcp: true,
667 imports: true,
668 cross_platform: true,
669 agents_md: true,
670 copilot: true,
671 cursor: true,
672 cline: true,
673 opencode: true,
674 gemini_md: true,
675 codex: true,
676 roo_code: true,
677 windsurf: true,
678 kiro_steering: true,
679 kiro_agents: true,
680 amp_checks: true,
681 prompt_engineering: true,
682 generic_instructions: true,
683 frontmatter_validation: true,
684 xml_balance: true,
685 import_references: true,
686 disabled_rules: Vec::new(),
687 disabled_validators: Vec::new(),
688 }
689 }
690}
691
692#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
693#[schemars(
694 description = "Target tool for validation (deprecated: use 'tools' array for multi-tool support)"
695)]
696pub enum TargetTool {
697 /// Generic Agent Skills standard
698 Generic,
699 /// Claude Code specific
700 ClaudeCode,
701 /// Cursor specific
702 Cursor,
703 /// Codex specific
704 Codex,
705 /// Kiro specific
706 Kiro,
707}
708
709impl LintConfig {
710 /// Load config from file
711 pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
712 let content = safe_read_file(path.as_ref())?;
713 let config = toml::from_str(&content)?;
714 Ok(config)
715 }
716
717 /// Load config or use default, returning any parse warning
718 ///
719 /// Returns a tuple of (config, optional_warning). If a config path is provided
720 /// but the file cannot be loaded or parsed, returns the default config with a
721 /// warning message describing the error. This prevents silent fallback to
722 /// defaults on config typos or missing/unreadable config files.
723 pub fn load_or_default(path: Option<&PathBuf>) -> (Self, Option<String>) {
724 match path {
725 Some(p) => match Self::load(p) {
726 Ok(config) => (config, None),
727 Err(e) => {
728 let warning = t!(
729 "core.config.load_warning",
730 path = p.display().to_string(),
731 error = e.to_string()
732 );
733 (Self::default(), Some(warning.to_string()))
734 }
735 },
736 None => (Self::default(), None),
737 }
738 }
739
740 // =========================================================================
741 // Runtime Context Accessors
742 // =========================================================================
743 //
744 // These methods delegate to RuntimeContext, maintaining the same public API.
745 // =========================================================================
746
747 /// Get the runtime validation root directory, if set.
748 #[inline]
749 pub fn root_dir(&self) -> Option<&PathBuf> {
750 self.runtime.root_dir.as_ref()
751 }
752
753 /// Alias for `root_dir()` for consistency with other accessors.
754 #[inline]
755 pub fn get_root_dir(&self) -> Option<&PathBuf> {
756 self.root_dir()
757 }
758
759 /// Set the runtime validation root directory (not persisted).
760 pub fn set_root_dir(&mut self, root_dir: PathBuf) {
761 self.runtime.root_dir = Some(root_dir);
762 }
763
764 /// Set the shared import cache for project-level validation (not persisted).
765 ///
766 /// When set, the ImportsValidator will use this cache to share parsed
767 /// import data across files, improving performance by avoiding redundant
768 /// parsing during import chain traversal.
769 pub fn set_import_cache(&mut self, cache: crate::parsers::ImportCache) {
770 self.runtime.import_cache = Some(cache);
771 }
772
773 /// Get the shared import cache, if one has been set.
774 ///
775 /// Returns `None` for single-file validation or when the cache hasn't
776 /// been initialized. Returns `Some(&ImportCache)` during project-level
777 /// validation where import results are shared across files.
778 #[inline]
779 pub fn import_cache(&self) -> Option<&crate::parsers::ImportCache> {
780 self.runtime.import_cache.as_ref()
781 }
782
783 /// Alias for `import_cache()` for consistency with other accessors.
784 #[inline]
785 pub fn get_import_cache(&self) -> Option<&crate::parsers::ImportCache> {
786 self.import_cache()
787 }
788
789 /// Get the file system abstraction.
790 ///
791 /// Validators should use this for file system operations instead of
792 /// directly calling `std::fs` functions. This enables unit testing
793 /// with `MockFileSystem`.
794 pub fn fs(&self) -> &Arc<dyn FileSystem> {
795 &self.runtime.fs
796 }
797
798 /// Set the file system abstraction (not persisted).
799 ///
800 /// This is primarily used for testing with `MockFileSystem`.
801 ///
802 /// # Important
803 ///
804 /// This should only be called during configuration setup, before validation
805 /// begins. Changing the filesystem during validation may cause inconsistent
806 /// results if validators have already cached file state.
807 pub fn set_fs(&mut self, fs: Arc<dyn FileSystem>) {
808 self.runtime.fs = fs;
809 }
810
811 // =========================================================================
812 // Serializable Field Getters
813 // =========================================================================
814
815 /// Get the severity level threshold.
816 #[inline]
817 pub fn severity(&self) -> SeverityLevel {
818 self.data.severity
819 }
820
821 /// Get the rules configuration.
822 #[inline]
823 pub fn rules(&self) -> &RuleConfig {
824 &self.data.rules
825 }
826
827 /// Get the exclude patterns.
828 #[inline]
829 pub fn exclude(&self) -> &[String] {
830 &self.data.exclude
831 }
832
833 /// Get the target tool.
834 #[inline]
835 pub fn target(&self) -> TargetTool {
836 self.data.target
837 }
838
839 /// Get the tools list.
840 #[inline]
841 pub fn tools(&self) -> &[String] {
842 &self.data.tools
843 }
844
845 /// Get the tool versions configuration.
846 #[inline]
847 pub fn tool_versions(&self) -> &ToolVersions {
848 &self.data.tool_versions
849 }
850
851 /// Get the spec revisions configuration.
852 #[inline]
853 pub fn spec_revisions(&self) -> &SpecRevisions {
854 &self.data.spec_revisions
855 }
856
857 /// Get the files configuration.
858 #[inline]
859 pub fn files_config(&self) -> &FilesConfig {
860 &self.data.files
861 }
862
863 /// Get the locale, if set.
864 #[inline]
865 pub fn locale(&self) -> Option<&str> {
866 self.data.locale.as_deref()
867 }
868
869 /// Get the maximum number of files to validate.
870 #[inline]
871 pub fn max_files_to_validate(&self) -> Option<usize> {
872 self.data.max_files_to_validate
873 }
874
875 /// Get the raw `mcp_protocol_version` field value (without fallback logic).
876 ///
877 /// For the resolved version with fallback, use [`get_mcp_protocol_version()`](Self::get_mcp_protocol_version).
878 #[inline]
879 pub fn mcp_protocol_version_raw(&self) -> Option<&str> {
880 self.data.mcp_protocol_version.as_deref()
881 }
882
883 // =========================================================================
884 // Serializable Field Setters
885 // =========================================================================
886 //
887 // All setters use `Arc::make_mut` for copy-on-write semantics. When the
888 // Arc is uniquely owned (refcount == 1), the data is mutated in place
889 // with no allocation. When shared, a clone is made first.
890 // =========================================================================
891
892 /// Set the severity level threshold.
893 pub fn set_severity(&mut self, severity: SeverityLevel) {
894 Arc::make_mut(&mut self.data).severity = severity;
895 }
896
897 /// Set the target tool.
898 pub fn set_target(&mut self, target: TargetTool) {
899 Arc::make_mut(&mut self.data).target = target;
900 }
901
902 /// Set the tools list.
903 pub fn set_tools(&mut self, tools: Vec<String>) {
904 Arc::make_mut(&mut self.data).tools = tools;
905 }
906
907 /// Get a mutable reference to the tools list.
908 pub fn tools_mut(&mut self) -> &mut Vec<String> {
909 &mut Arc::make_mut(&mut self.data).tools
910 }
911
912 /// Set the exclude patterns.
913 ///
914 /// Note: This does not validate the patterns. Call [`validate()`](Self::validate)
915 /// after using this if validation is needed.
916 pub fn set_exclude(&mut self, exclude: Vec<String>) {
917 Arc::make_mut(&mut self.data).exclude = exclude;
918 }
919
920 /// Set the locale.
921 pub fn set_locale(&mut self, locale: Option<String>) {
922 Arc::make_mut(&mut self.data).locale = locale;
923 }
924
925 /// Set the maximum number of files to validate.
926 pub fn set_max_files_to_validate(&mut self, max: Option<usize>) {
927 Arc::make_mut(&mut self.data).max_files_to_validate = max;
928 }
929
930 /// Set the MCP protocol version (deprecated field).
931 pub fn set_mcp_protocol_version(&mut self, version: Option<String>) {
932 Arc::make_mut(&mut self.data).mcp_protocol_version = version;
933 }
934
935 /// Get a mutable reference to the rules configuration.
936 pub fn rules_mut(&mut self) -> &mut RuleConfig {
937 &mut Arc::make_mut(&mut self.data).rules
938 }
939
940 /// Get a mutable reference to the tool versions configuration.
941 pub fn tool_versions_mut(&mut self) -> &mut ToolVersions {
942 &mut Arc::make_mut(&mut self.data).tool_versions
943 }
944
945 /// Get a mutable reference to the spec revisions configuration.
946 pub fn spec_revisions_mut(&mut self) -> &mut SpecRevisions {
947 &mut Arc::make_mut(&mut self.data).spec_revisions
948 }
949
950 /// Get a mutable reference to the files configuration.
951 ///
952 /// Note: Mutations bypass builder validation. Call [`validate()`](Self::validate)
953 /// after modifying if validation is needed.
954 pub fn files_mut(&mut self) -> &mut FilesConfig {
955 &mut Arc::make_mut(&mut self.data).files
956 }
957
958 // =========================================================================
959 // Derived / Computed Accessors
960 // =========================================================================
961
962 /// Get the expected MCP protocol version
963 ///
964 /// Priority: spec_revisions.mcp_protocol > mcp_protocol_version > default
965 #[inline]
966 pub fn get_mcp_protocol_version(&self) -> &str {
967 self.data
968 .spec_revisions
969 .mcp_protocol
970 .as_deref()
971 .or(self.data.mcp_protocol_version.as_deref())
972 .unwrap_or(DEFAULT_MCP_PROTOCOL_VERSION)
973 }
974
975 /// Check if MCP protocol revision is explicitly pinned
976 #[inline]
977 pub fn is_mcp_revision_pinned(&self) -> bool {
978 self.data.spec_revisions.mcp_protocol.is_some() || self.data.mcp_protocol_version.is_some()
979 }
980
981 /// Check if Claude Code version is explicitly pinned
982 #[inline]
983 pub fn is_claude_code_version_pinned(&self) -> bool {
984 self.data.tool_versions.claude_code.is_some()
985 }
986
987 /// Get the pinned Claude Code version, if any
988 #[inline]
989 pub fn get_claude_code_version(&self) -> Option<&str> {
990 self.data.tool_versions.claude_code.as_deref()
991 }
992}
993
994#[cfg(test)]
995mod tests;