Skip to main content

agnix_core/rules/
mod.rs

1//! Validation rules
2
3pub mod agent;
4pub mod agents_md;
5pub mod amp;
6pub mod claude_md;
7pub mod claude_rules;
8pub mod claude_settings;
9pub mod cline;
10pub mod codex;
11pub mod codex_plugin;
12pub mod copilot;
13pub mod cross_platform;
14pub mod cursor;
15pub mod gemini_agent;
16pub mod gemini_extension;
17pub mod gemini_ignore;
18pub mod gemini_md;
19pub mod gemini_settings;
20pub mod hooks;
21pub mod imports;
22pub mod kiro_agent;
23pub mod kiro_hook;
24pub mod kiro_mcp;
25pub mod kiro_power;
26pub mod kiro_settings;
27pub mod kiro_steering;
28pub mod mcp;
29pub mod opencode;
30pub mod output_style;
31pub mod per_client_skill;
32pub mod plugin;
33pub mod project_level;
34pub mod prompt;
35pub mod roo;
36pub mod skill;
37pub mod windsurf;
38pub mod xml;
39
40use crate::{config::LintConfig, diagnostics::Diagnostic};
41use std::path::Path;
42
43/// Shared secret-detection helper used by kiro_steering, kiro_power, kiro_mcp,
44/// and kiro_hook validators. Determines whether a captured value looks like a
45/// plaintext secret rather than a template/variable reference.
46///
47/// Returns `false` for empty values, template expressions (`${...}`, `$(...)`,
48/// `{{...}}`, `<...>`), and values shorter than 8 characters (too short to be
49/// a real secret).
50pub(crate) fn seems_plaintext_secret(value: &str) -> bool {
51    let trimmed = value.trim_matches(|ch| ch == '"' || ch == '\'').trim();
52    !trimmed.is_empty()
53        && !trimmed.starts_with("${")
54        && !trimmed.starts_with("$(")
55        && !trimmed.starts_with("{{")
56        && !trimmed.starts_with('<')
57        && !trimmed.starts_with("env:")
58        && trimmed.len() >= 8
59}
60
61/// Compute 1-based (line, column) from a byte offset in `content`.
62///
63/// Shared by kiro_steering and kiro_agent validators.
64pub(crate) fn line_col_at_offset(content: &str, offset: usize) -> (usize, usize) {
65    let mut line = 1usize;
66    let mut col = 1usize;
67
68    for (idx, ch) in content.char_indices() {
69        if idx >= offset {
70            break;
71        }
72        if ch == '\n' {
73            line += 1;
74            col = 1;
75        } else {
76            col += 1;
77        }
78    }
79
80    (line, col)
81}
82
83/// Extract the short (unqualified) type name from `std::any::type_name`.
84///
85/// Given a fully-qualified path like `"agnix_core::rules::skill::SkillValidator"`,
86/// returns `"SkillValidator"`. For generic types like `"Wrapper<foo::Bar>"`,
87/// strips the generic suffix first, yielding `"Wrapper"`.
88/// Falls back to the full name when no `::` separator is found.
89fn short_type_name<T: ?Sized + 'static>() -> &'static str {
90    let full = std::any::type_name::<T>();
91    // Strip generic suffix (e.g., "Wrapper<foo::Bar>" -> "Wrapper")
92    let base = full.split('<').next().unwrap_or(full);
93    base.rsplit("::").next().unwrap_or(base)
94}
95
96/// Metadata for a validator, providing introspection capabilities.
97///
98/// Returned by [`Validator::metadata`] to expose the validator's name and
99/// the set of rule IDs it can emit during validation.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct ValidatorMetadata {
102    /// Human-readable validator name (e.g. `"SkillValidator"`).
103    pub name: &'static str,
104    /// Rule IDs this validator can emit (e.g. `&["AS-001", "AS-002"]`).
105    pub rule_ids: &'static [&'static str],
106}
107
108/// Trait for file validators.
109///
110/// Implementors define validation logic for specific file types. Each validator
111/// is created by a [`ValidatorFactory`](crate::ValidatorFactory) registered in
112/// the [`ValidatorRegistry`](crate::ValidatorRegistry).
113///
114/// The [`name()`](Validator::name) method returns a human-readable identifier
115/// used for filtering via `disabled_validators` configuration. The default
116/// implementation derives the name from the concrete struct name (e.g.,
117/// `"SkillValidator"`).
118///
119/// Implementations must be `Send + Sync + 'static` - validators are cached in
120/// [`ValidatorRegistry`](crate::ValidatorRegistry) and shared across threads
121/// (e.g., via `Arc<ValidatorRegistry>` in the LSP server). Implementations
122/// must not hold non-static references or non-thread-safe interior mutability.
123pub trait Validator: Send + Sync + 'static {
124    /// Validate the given file content and return any diagnostics.
125    fn validate(&self, path: &Path, content: &str, config: &LintConfig) -> Vec<Diagnostic>;
126
127    /// Return a short, human-readable name for this validator.
128    ///
129    /// Used by [`ValidatorRegistry`](crate::ValidatorRegistry) to support
130    /// `disabled_validators` filtering. The default implementation extracts
131    /// the unqualified struct name (e.g., `"SkillValidator"`).
132    ///
133    /// Override this if the auto-derived name is unsuitable (e.g., for
134    /// dynamically-generated validators from plugins).
135    fn name(&self) -> &'static str {
136        short_type_name::<Self>()
137    }
138
139    /// Returns metadata describing this validator.
140    ///
141    /// The default implementation returns the validator's [`name`](Validator::name)
142    /// with an empty `rule_ids` slice. Built-in validators override this to
143    /// advertise the full set of rule IDs they can emit from their
144    /// [`validate`](Validator::validate) method.
145    ///
146    /// Note: `rule_ids` covers only rules emitted directly by the validator.
147    /// Project-level cross-file rules (e.g. `AGM-006`, `XP-004`..`XP-006`,
148    /// `VER-001`) live in `rules::project_level` and are not attributed to any validator.
149    fn metadata(&self) -> ValidatorMetadata {
150        ValidatorMetadata {
151            name: self.name(),
152            rule_ids: &[],
153        }
154    }
155}
156
157/// Trait for frontmatter types that support value range finding.
158/// Both ParsedFrontmatter (copilot) and ParsedMdcFrontmatter (cursor) implement this.
159pub(crate) trait FrontmatterRanges {
160    fn raw_content(&self) -> &str;
161    /// Return the 1-based line number of the opening `---` delimiter in the full file.
162    ///
163    /// This anchors frontmatter-relative line indices to absolute file positions:
164    /// `find_yaml_value_range` computes `start_line() + 1 + idx` to locate the
165    /// absolute line of each frontmatter key.
166    fn start_line(&self) -> usize;
167}
168
169/// Return the human-readable JSON type name for a `serde_json::Value`.
170pub(crate) fn json_type_name(value: &serde_json::Value) -> &'static str {
171    match value {
172        serde_json::Value::Null => "null",
173        serde_json::Value::Bool(_) => "boolean",
174        serde_json::Value::Number(_) => "number",
175        serde_json::Value::String(_) => "string",
176        serde_json::Value::Array(_) => "array",
177        serde_json::Value::Object(_) => "object",
178    }
179}
180
181/// Find the byte range of a line in content (1-indexed line numbers).
182/// Returns (start_byte, end_byte) including the newline character.
183pub(crate) fn line_byte_range(content: &str, line_number: usize) -> Option<(usize, usize)> {
184    if line_number == 0 {
185        return None;
186    }
187
188    let mut current_line = 1usize;
189    let mut line_start = 0usize;
190
191    for (idx, ch) in content.char_indices() {
192        if current_line == line_number && ch == '\n' {
193            return Some((line_start, idx + 1));
194        }
195        if ch == '\n' {
196            current_line += 1;
197            line_start = idx + 1;
198        }
199    }
200
201    if current_line == line_number {
202        Some((line_start, content.len()))
203    } else {
204        None
205    }
206}
207
208/// Compute the byte offset where frontmatter content begins - after the opening
209/// `---` delimiter and its line ending. This is the correct insertion point for
210/// new frontmatter keys.
211///
212/// Since `split_frontmatter` already advances `frontmatter_start` past the
213/// newline following `---`, this function simply returns the value as-is.
214/// The signature is kept for backward compatibility.
215pub(crate) fn frontmatter_content_offset(_content: &str, frontmatter_start: usize) -> usize {
216    frontmatter_start
217}
218
219/// Find the byte range of a YAML value for a given key in frontmatter.
220/// Returns the range including quotes if the value is quoted.
221/// Handles `#` comments correctly (ignores them inside quotes).
222pub(crate) fn find_yaml_value_range<T: FrontmatterRanges>(
223    full_content: &str,
224    parsed: &T,
225    key: &str,
226    include_quotes: bool,
227) -> Option<(usize, usize)> {
228    for (idx, line) in parsed.raw_content().lines().enumerate() {
229        let trimmed = line.trim_start();
230        if let Some(rest) = trimmed.strip_prefix(key) {
231            if let Some(after_colon) = rest.trim_start().strip_prefix(':') {
232                let after_colon_trimmed = after_colon.trim();
233
234                // Handle quoted values (# inside quotes is literal, not a comment)
235                let value_str = if let Some(inner) = after_colon_trimmed.strip_prefix('"') {
236                    if let Some(end_quote_idx) = inner.find('"') {
237                        let quoted = &after_colon_trimmed[..end_quote_idx + 2];
238                        if include_quotes {
239                            quoted
240                        } else {
241                            &quoted[1..quoted.len() - 1]
242                        }
243                    } else {
244                        after_colon_trimmed
245                    }
246                } else if let Some(inner) = after_colon_trimmed.strip_prefix('\'') {
247                    if let Some(end_quote_idx) = inner.find('\'') {
248                        let quoted = &after_colon_trimmed[..end_quote_idx + 2];
249                        if include_quotes {
250                            quoted
251                        } else {
252                            &quoted[1..quoted.len() - 1]
253                        }
254                    } else {
255                        after_colon_trimmed
256                    }
257                } else {
258                    // Unquoted value: strip comments
259                    after_colon_trimmed.split('#').next().unwrap_or("").trim()
260                };
261
262                if value_str.is_empty() {
263                    continue;
264                }
265                let line_num = parsed.start_line() + 1 + idx;
266                let (line_start, _) = line_byte_range(full_content, line_num)?;
267                let line_content = &full_content[line_start..];
268                let val_offset = line_content.find(value_str)?;
269                let abs_start = line_start + val_offset;
270                let abs_end = abs_start + value_str.len();
271                return Some((abs_start, abs_end));
272            }
273        }
274    }
275    None
276}
277
278/// Find the byte span of a JSON string value for a unique key/value pair.
279/// Returns byte positions of the inner string (without quotes).
280/// Returns None if the key/value pair is not found or appears more than once (uniqueness guard).
281pub(crate) fn find_unique_json_string_value_span(
282    content: &str,
283    key: &str,
284    current_value: &str,
285) -> Option<(usize, usize)> {
286    crate::span_utils::find_unique_json_string_inner(content, key, current_value)
287}
288
289/// Find the closest valid value for an invalid input.
290/// Returns an exact case-insensitive match first, then a substring match,
291/// or None if no plausible match is found.
292///
293/// Uses ASCII case folding — all valid values in agnix are ASCII identifiers
294/// (agent names, scope names, transport types). The 3-byte minimum for
295/// substring matching uses byte length, which equals char count for ASCII.
296pub(crate) fn find_closest_value<'a>(invalid: &str, valid_values: &[&'a str]) -> Option<&'a str> {
297    if invalid.is_empty() {
298        return None;
299    }
300    // Case-insensitive exact match (no allocation)
301    for &v in valid_values {
302        if v.eq_ignore_ascii_case(invalid) {
303            return Some(v);
304        }
305    }
306    // Substring match — require minimum 3 chars to avoid spurious matches
307    if invalid.len() < 3 {
308        return None;
309    }
310    let lower = invalid.to_ascii_lowercase();
311    valid_values
312        .iter()
313        .find(|&&v| {
314            contains_ignore_ascii_case(v.as_bytes(), lower.as_bytes())
315                || contains_ignore_ascii_case(lower.as_bytes(), v.as_bytes())
316        })
317        .copied()
318}
319
320/// Check if `haystack` contains `needle` using ASCII case-insensitive comparison.
321/// Zero allocations — operates directly on byte slices.
322fn contains_ignore_ascii_case(haystack: &[u8], needle: &[u8]) -> bool {
323    if needle.is_empty() || needle.len() > haystack.len() {
324        return false;
325    }
326    haystack
327        .windows(needle.len())
328        .any(|window| window.eq_ignore_ascii_case(needle))
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_find_closest_value_exact_case_insensitive() {
337        assert_eq!(
338            find_closest_value("Stdio", &["stdio", "http", "sse"]),
339            Some("stdio")
340        );
341        assert_eq!(
342            find_closest_value("HTTP", &["stdio", "http", "sse"]),
343            Some("http")
344        );
345    }
346
347    #[test]
348    fn test_find_closest_value_substring_match() {
349        assert_eq!(
350            find_closest_value("code", &["code-review", "coding-agent"]),
351            Some("code-review")
352        );
353        assert_eq!(
354            find_closest_value("coding-agent-v2", &["code-review", "coding-agent"]),
355            Some("coding-agent")
356        );
357    }
358
359    #[test]
360    fn test_find_closest_value_no_match() {
361        assert_eq!(
362            find_closest_value("nonsense", &["stdio", "http", "sse"]),
363            None
364        );
365        assert_eq!(
366            find_closest_value("xyz", &["code-review", "coding-agent"]),
367            None
368        );
369    }
370
371    #[test]
372    fn test_find_closest_value_empty_input() {
373        assert_eq!(find_closest_value("", &["stdio", "http", "sse"]), None);
374    }
375
376    #[test]
377    fn test_find_closest_value_exact_preferred_over_substring() {
378        // "user" matches exactly, not as substring of "user-project"
379        assert_eq!(
380            find_closest_value("User", &["user", "project", "local"]),
381            Some("user")
382        );
383    }
384
385    #[test]
386    fn test_find_closest_value_short_input_no_substring() {
387        // Inputs shorter than 3 chars should only match exactly, not as substrings
388        assert_eq!(
389            find_closest_value("ss", &["stdio", "http", "sse"]),
390            None,
391            "2-char input should not substring-match"
392        );
393        assert_eq!(
394            find_closest_value("a", &["coding-agent", "code-review"]),
395            None,
396            "1-char input should not substring-match"
397        );
398        // But short exact matches still work
399        assert_eq!(
400            find_closest_value("SS", &["stdio", "http", "ss"]),
401            Some("ss"),
402            "2-char exact match (case-insensitive) should still work"
403        );
404    }
405
406    #[test]
407    fn test_validator_metadata_default_has_empty_rule_ids() {
408        struct DummyValidator;
409        impl Validator for DummyValidator {
410            fn validate(&self, _: &Path, _: &str, _: &LintConfig) -> Vec<Diagnostic> {
411                vec![]
412            }
413        }
414        let v = DummyValidator;
415        let meta = v.metadata();
416        assert_eq!(meta.name, "DummyValidator");
417        assert!(meta.rule_ids.is_empty());
418    }
419
420    #[test]
421    fn test_validator_metadata_custom_override() {
422        const IDS: &[&str] = &["TEST-001", "TEST-002"];
423        struct CustomValidator;
424        impl Validator for CustomValidator {
425            fn validate(&self, _: &Path, _: &str, _: &LintConfig) -> Vec<Diagnostic> {
426                vec![]
427            }
428            fn metadata(&self) -> ValidatorMetadata {
429                ValidatorMetadata {
430                    name: "CustomValidator",
431                    rule_ids: IDS,
432                }
433            }
434        }
435        let v = CustomValidator;
436        let meta = v.metadata();
437        assert_eq!(meta.name, "CustomValidator");
438        assert_eq!(meta.rule_ids, &["TEST-001", "TEST-002"]);
439    }
440
441    #[test]
442    fn test_validator_metadata_is_copy() {
443        let meta = ValidatorMetadata {
444            name: "Test",
445            rule_ids: &["R-001"],
446        };
447        let copy = meta;
448        assert_eq!(meta, copy);
449    }
450}