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