use crate::{
config::LintConfig,
diagnostics::{Diagnostic, Fix},
parsers::frontmatter::split_frontmatter,
rules::{Validator, ValidatorMetadata, line_col_at_offset, seems_plaintext_secret},
};
use regex::Regex;
use rust_i18n::t;
use std::path::{Component, Path};
use std::sync::OnceLock;
const RULE_IDS: &[&str] = &[
"KIRO-001", "KIRO-002", "KIRO-003", "KIRO-004", "KIRO-005", "KIRO-006", "KIRO-007", "KIRO-008",
"KIRO-009", "KIRO-010", "KIRO-011", "KIRO-012", "KIRO-013", "KIRO-014",
];
const MAX_STEERING_DOC_LENGTH: usize = 50_000;
const VALID_INCLUSION_MODES: &[&str] = &["always", "fileMatch", "manual", "auto"];
const VALID_FRONTMATTER_FIELDS: &[&str] = &["inclusion", "name", "description", "fileMatchPattern"];
fn find_frontmatter_key_line(frontmatter: &str, key: &str) -> usize {
for (idx, line) in frontmatter.lines().enumerate() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix(key)
&& rest.trim_start().starts_with(':')
{
return idx + 2;
}
}
1
}
fn secret_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?im)\b(?P<marker>api[_-]?key|token|password|[a-z0-9_-]+secret|secret[a-z0-9_-]+)\b\s*[:=]\s*(?P<value>[^\s#]+)",
)
.expect("secret pattern must compile")
})
}
fn inline_file_ref_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"#\[\[file:(?P<path>[^\]\n]+)\]\]").expect("inline file pattern must compile")
})
}
fn has_parent_dir_traversal(reference: &str) -> bool {
Path::new(reference)
.components()
.any(|component| matches!(component, Component::ParentDir))
}
struct FrontmatterAdapter<'a> {
raw: &'a str,
}
impl crate::rules::FrontmatterRanges for FrontmatterAdapter<'_> {
fn raw_content(&self) -> &str {
self.raw
}
fn start_line(&self) -> usize {
1 }
}
pub struct KiroSteeringValidator;
impl Validator for KiroSteeringValidator {
fn metadata(&self) -> ValidatorMetadata {
ValidatorMetadata {
name: self.name(),
rule_ids: RULE_IDS,
}
}
fn validate(&self, path: &Path, content: &str, config: &LintConfig) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
if config.is_rule_enabled("KIRO-004") && content.trim().is_empty() {
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"KIRO-004",
t!("rules.kiro_004.message"),
)
.with_suggestion(t!("rules.kiro_004.suggestion")),
);
return diagnostics;
}
if config.is_rule_enabled("KIRO-006") {
for captures in secret_pattern().captures_iter(content) {
let Some(full_match) = captures.get(0) else {
continue;
};
let marker = captures
.name("marker")
.map(|m| m.as_str().to_ascii_lowercase())
.unwrap_or_else(|| "secret".to_string());
let value = captures
.name("value")
.map(|m| m.as_str())
.unwrap_or_default();
if seems_plaintext_secret(value) {
let (line, col) = line_col_at_offset(content, full_match.start());
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
col,
"KIRO-006",
t!("rules.kiro_006.message", marker = marker),
)
.with_suggestion(t!("rules.kiro_006.suggestion")),
);
break;
}
}
}
if config.is_rule_enabled("KIRO-009") {
let fs = config.fs();
for captures in inline_file_ref_pattern().captures_iter(content) {
let Some(full_match) = captures.get(0) else {
continue;
};
let Some(path_match) = captures.name("path") else {
continue;
};
let reference = path_match.as_str().trim();
if reference.is_empty()
|| reference.starts_with("http://")
|| reference.starts_with("https://")
|| reference.starts_with('/')
|| Path::new(reference).is_absolute()
|| has_parent_dir_traversal(reference)
{
continue;
}
let resolved = path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(reference);
if !fs.exists(&resolved) {
let (line, col) = line_col_at_offset(content, full_match.start());
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
line,
col,
"KIRO-009",
t!("rules.kiro_009.message", reference = reference),
)
.with_suggestion(t!("rules.kiro_009.suggestion")),
);
}
}
}
let parts = split_frontmatter(content);
if !parts.has_frontmatter || !parts.has_closing {
return diagnostics; }
if config.is_rule_enabled("KIRO-005") && parts.body.trim().is_empty() {
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"KIRO-005",
t!("rules.kiro_005.message"),
)
.with_suggestion(t!("rules.kiro_005.suggestion")),
);
}
let yaml: serde_yaml::Value = match serde_yaml::from_str(&parts.frontmatter) {
Ok(v) => v,
Err(_) => return diagnostics, };
let mapping = match yaml.as_mapping() {
Some(m) => m,
None => return diagnostics,
};
let key_inclusion = serde_yaml::Value::String("inclusion".into());
let key_name = serde_yaml::Value::String("name".into());
let key_description = serde_yaml::Value::String("description".into());
let key_file_match_pattern = serde_yaml::Value::String("fileMatchPattern".into());
let inclusion_val = mapping.get(&key_inclusion);
let inclusion_str = inclusion_val.and_then(|v| v.as_str());
if config.is_rule_enabled("KIRO-001") {
if let Some(val) = inclusion_val {
match val.as_str() {
Some(inclusion) if VALID_INCLUSION_MODES.contains(&inclusion) => {
}
Some(inclusion) => {
let mut diagnostic = Diagnostic::error(
path.to_path_buf(),
1,
0,
"KIRO-001",
t!("rules.kiro_001.message", value = inclusion),
)
.with_suggestion(t!("rules.kiro_001.suggestion"));
if let Some(suggested) =
crate::rules::find_closest_value(inclusion, VALID_INCLUSION_MODES)
{
let adapter = FrontmatterAdapter {
raw: &parts.frontmatter,
};
if let Some((start, end)) = crate::rules::find_yaml_value_range(
content,
&adapter,
"inclusion",
true,
) {
let slice = content.get(start..end).unwrap_or("");
let replacement = if slice.starts_with('"') {
format!("\"{}\"", suggested)
} else if slice.starts_with('\'') {
format!("'{}'", suggested)
} else {
suggested.to_string()
};
diagnostic = diagnostic.with_fix(Fix::replace(
start,
end,
replacement,
format!("Replace inclusion mode with '{}'", suggested),
false,
));
}
}
diagnostics.push(diagnostic);
}
None => {
let display = format!("{val:?}");
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
1,
0,
"KIRO-001",
t!("rules.kiro_001.message", value = display),
)
.with_suggestion(t!("rules.kiro_001.suggestion")),
);
}
}
}
}
if config.is_rule_enabled("KIRO-002") {
if let Some(mode) = inclusion_str {
match mode {
"auto" => {
let name_valid = mapping
.get(&key_name)
.and_then(|v| v.as_str())
.is_some_and(|s| !s.trim().is_empty());
let desc_valid = mapping
.get(&key_description)
.and_then(|v| v.as_str())
.is_some_and(|s| !s.trim().is_empty());
if !name_valid {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
1,
0,
"KIRO-002",
t!("rules.kiro_002_auto.message", field = "name"),
)
.with_suggestion(t!("rules.kiro_002_auto.suggestion")),
);
}
if !desc_valid {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
1,
0,
"KIRO-002",
t!("rules.kiro_002_auto.message", field = "description"),
)
.with_suggestion(t!("rules.kiro_002_auto.suggestion")),
);
}
}
"fileMatch" if !mapping.contains_key(&key_file_match_pattern) => {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
1,
0,
"KIRO-002",
t!("rules.kiro_002_filematch.message"),
)
.with_suggestion(t!("rules.kiro_002_filematch.suggestion")),
);
}
_ => {} }
}
}
if config.is_rule_enabled("KIRO-003") {
if let Some(pattern_val) = mapping.get(&key_file_match_pattern) {
match pattern_val.as_str() {
Some(pattern) => {
if let Err(e) = glob::Pattern::new(pattern) {
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"KIRO-003",
t!("rules.kiro_003.message", error = e.to_string()),
)
.with_suggestion(t!("rules.kiro_003.suggestion")),
);
}
}
None => {
let display = format!("{pattern_val:?}");
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"KIRO-003",
t!(
"rules.kiro_003.message",
error = format!("expected string, got {display}")
),
)
.with_suggestion(t!("rules.kiro_003.suggestion")),
);
}
}
}
}
if config.is_rule_enabled("KIRO-007")
&& mapping.contains_key(&key_file_match_pattern)
&& !matches!(inclusion_str, Some("fileMatch"))
{
let inclusion_display = inclusion_str.unwrap_or("<missing>");
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
find_frontmatter_key_line(&parts.frontmatter, "fileMatchPattern"),
0,
"KIRO-007",
t!("rules.kiro_007.message", inclusion = inclusion_display),
)
.with_suggestion(t!("rules.kiro_007.suggestion")),
);
}
if config.is_rule_enabled("KIRO-008") {
for key in mapping.keys() {
let Some(field) = key.as_str() else {
continue;
};
if VALID_FRONTMATTER_FIELDS.contains(&field) {
continue;
}
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
find_frontmatter_key_line(&parts.frontmatter, field),
0,
"KIRO-008",
t!("rules.kiro_008.message", field = field),
)
.with_suggestion(t!("rules.kiro_008.suggestion")),
);
}
}
if config.is_rule_enabled("KIRO-010") && inclusion_val.is_none() {
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"KIRO-010",
t!("rules.kiro_010.message"),
)
.with_suggestion(t!("rules.kiro_010.suggestion")),
);
}
if config.is_rule_enabled("KIRO-011") && content.len() > MAX_STEERING_DOC_LENGTH {
diagnostics.push(
Diagnostic::info(
path.to_path_buf(),
1,
0,
"KIRO-011",
t!(
"rules.kiro_011.message",
size = &content.len().to_string(),
limit = &MAX_STEERING_DOC_LENGTH.to_string()
),
)
.with_suggestion(t!("rules.kiro_011.suggestion")),
);
}
if config.is_rule_enabled("KIRO-013") {
let inclusion_count = parts
.frontmatter
.lines()
.filter(|line| {
let trimmed = line.trim_start();
trimmed.starts_with("inclusion:") || trimmed.starts_with("inclusion :")
})
.count();
if inclusion_count > 1 {
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"KIRO-013",
t!("rules.kiro_013.message"),
)
.with_suggestion(t!("rules.kiro_013.suggestion")),
);
}
}
if config.is_rule_enabled("KIRO-014") {
let body = parts.body.trim();
if !body.is_empty() && !body.starts_with('#') && !body.contains("\n#") {
diagnostics.push(
Diagnostic::info(
path.to_path_buf(),
1,
0,
"KIRO-014",
t!("rules.kiro_014.message"),
)
.with_suggestion(t!("rules.kiro_014.suggestion")),
);
}
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::LintConfig;
use crate::diagnostics::DiagnosticLevel;
fn validate(path: &str, content: &str) -> Vec<Diagnostic> {
let validator = KiroSteeringValidator;
validator.validate(Path::new(path), content, &LintConfig::default())
}
fn validate_steering(content: &str) -> Vec<Diagnostic> {
validate(".kiro/steering/test.md", content)
}
#[test]
fn test_kiro_001_invalid_mode() {
let content = "---\ninclusion: invalid_mode\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_001: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-001")
.collect();
assert_eq!(kiro_001.len(), 1);
assert_eq!(kiro_001[0].level, DiagnosticLevel::Error);
assert!(kiro_001[0].message.contains("invalid_mode"));
}
#[test]
fn test_kiro_001_valid_always() {
let content = "---\ninclusion: always\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_001: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-001")
.collect();
assert!(kiro_001.is_empty());
}
#[test]
fn test_kiro_001_valid_auto() {
let content = "---\ninclusion: auto\nname: test\ndescription: test desc\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_001: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-001")
.collect();
assert!(kiro_001.is_empty());
}
#[test]
fn test_kiro_001_valid_filematch() {
let content = "---\ninclusion: fileMatch\nfileMatchPattern: \"**/*.ts\"\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_001: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-001")
.collect();
assert!(kiro_001.is_empty());
}
#[test]
fn test_kiro_001_valid_manual() {
let content = "---\ninclusion: manual\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_001: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-001")
.collect();
assert!(kiro_001.is_empty());
}
#[test]
fn test_kiro_001_has_fix() {
let content = "---\ninclusion: Always\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_001: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-001")
.collect();
assert_eq!(kiro_001.len(), 1);
assert!(
kiro_001[0].has_fixes(),
"KIRO-001 should have auto-fix for case-mismatched inclusion mode"
);
let fix = &kiro_001[0].fixes[0];
assert!(!fix.safe, "KIRO-001 fix should be unsafe");
assert!(
fix.replacement.contains("always"),
"Fix should suggest 'always' as closest match, got: {}",
fix.replacement
);
let mut fixed = content.to_string();
fixed.replace_range(fix.start_byte..fix.end_byte, &fix.replacement);
assert!(
fixed.contains("inclusion: always"),
"Applied fix should produce valid content"
);
}
#[test]
fn test_kiro_001_disabled() {
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["KIRO-001".to_string()];
let validator = KiroSteeringValidator;
let diagnostics = validator.validate(
Path::new(".kiro/steering/test.md"),
"---\ninclusion: invalid_mode\n---\n# Steering\n",
&config,
);
let kiro_001: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-001")
.collect();
assert!(kiro_001.is_empty());
}
#[test]
fn test_kiro_002_auto_missing_name() {
let content = "---\ninclusion: auto\ndescription: test desc\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_002: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-002")
.collect();
assert_eq!(kiro_002.len(), 1);
assert_eq!(kiro_002[0].level, DiagnosticLevel::Error);
assert!(kiro_002[0].message.contains("name"));
}
#[test]
fn test_kiro_002_auto_missing_description() {
let content = "---\ninclusion: auto\nname: my-steering\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_002: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-002")
.collect();
assert_eq!(kiro_002.len(), 1);
assert!(kiro_002[0].message.contains("description"));
}
#[test]
fn test_kiro_002_auto_missing_both() {
let content = "---\ninclusion: auto\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_002: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-002")
.collect();
assert_eq!(kiro_002.len(), 2);
}
#[test]
fn test_kiro_002_auto_valid() {
let content =
"---\ninclusion: auto\nname: my-steering\ndescription: test desc\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_002: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-002")
.collect();
assert!(kiro_002.is_empty());
}
#[test]
fn test_kiro_002_filematch_missing_pattern() {
let content = "---\ninclusion: fileMatch\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_002: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-002")
.collect();
assert_eq!(kiro_002.len(), 1);
assert!(kiro_002[0].message.contains("fileMatchPattern"));
}
#[test]
fn test_kiro_002_filematch_valid() {
let content = "---\ninclusion: fileMatch\nfileMatchPattern: \"**/*.ts\"\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_002: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-002")
.collect();
assert!(kiro_002.is_empty());
}
#[test]
fn test_kiro_002_always_no_extra_fields_needed() {
let content = "---\ninclusion: always\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_002: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-002")
.collect();
assert!(kiro_002.is_empty());
}
#[test]
fn test_kiro_002_manual_no_extra_fields_needed() {
let content = "---\ninclusion: manual\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_002: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-002")
.collect();
assert!(kiro_002.is_empty());
}
#[test]
fn test_kiro_002_disabled() {
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["KIRO-002".to_string()];
let validator = KiroSteeringValidator;
let diagnostics = validator.validate(
Path::new(".kiro/steering/test.md"),
"---\ninclusion: auto\n---\n# Steering\n",
&config,
);
let kiro_002: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-002")
.collect();
assert!(kiro_002.is_empty());
}
#[test]
fn test_kiro_003_bad_glob() {
let content = "---\nfileMatchPattern: \"[unclosed\"\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_003: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-003")
.collect();
assert_eq!(kiro_003.len(), 1);
assert_eq!(kiro_003[0].level, DiagnosticLevel::Warning);
}
#[test]
fn test_kiro_003_valid_glob() {
let content = "---\nfileMatchPattern: \"**/*.ts\"\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_003: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-003")
.collect();
assert!(kiro_003.is_empty());
}
#[test]
fn test_kiro_003_disabled() {
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["KIRO-003".to_string()];
let validator = KiroSteeringValidator;
let diagnostics = validator.validate(
Path::new(".kiro/steering/test.md"),
"---\nfileMatchPattern: \"[unclosed\"\n---\n# Steering\n",
&config,
);
let kiro_003: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-003")
.collect();
assert!(kiro_003.is_empty());
}
#[test]
fn test_kiro_004_empty_file() {
let diagnostics = validate_steering("");
let kiro_004: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-004")
.collect();
assert_eq!(kiro_004.len(), 1);
assert_eq!(kiro_004[0].level, DiagnosticLevel::Warning);
}
#[test]
fn test_kiro_004_whitespace_only() {
let diagnostics = validate_steering(" \n\n ");
let kiro_004: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-004")
.collect();
assert_eq!(kiro_004.len(), 1);
}
#[test]
fn test_kiro_004_valid_file() {
let diagnostics = validate_steering("---\ninclusion: always\n---\n# Guidelines\n");
let kiro_004: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-004")
.collect();
assert!(kiro_004.is_empty());
}
#[test]
fn test_kiro_004_disabled() {
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["KIRO-004".to_string()];
let validator = KiroSteeringValidator;
let diagnostics = validator.validate(Path::new(".kiro/steering/test.md"), "", &config);
let kiro_004: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-004")
.collect();
assert!(kiro_004.is_empty());
}
#[test]
fn test_kiro_steering_category_disabled() {
let mut config = LintConfig::default();
config.rules_mut().kiro_steering = false;
let validator = KiroSteeringValidator;
let diagnostics = validator.validate(Path::new(".kiro/steering/test.md"), "", &config);
let kiro_rules: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule.starts_with("KIRO-"))
.collect();
assert!(kiro_rules.is_empty());
let diagnostics = validator.validate(
Path::new(".kiro/steering/test.md"),
"---\ninclusion: invalid\n---\n# Test\n",
&config,
);
let kiro_rules: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule.starts_with("KIRO-"))
.collect();
assert!(kiro_rules.is_empty());
}
#[test]
fn test_no_frontmatter_no_diagnostics() {
let diagnostics = validate_steering("# Just a heading\nSome content.");
assert!(diagnostics.is_empty());
}
#[test]
fn test_malformed_yaml_no_crash() {
let content = "---\ninclusion: auto\n bad: indentation\n---\n# Content\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.is_empty());
}
#[test]
fn test_non_mapping_yaml_no_crash() {
let content = "---\n- item1\n- item2\n---\n# Content\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.is_empty());
}
#[test]
fn test_kiro_001_non_string_inclusion_flagged() {
let content = "---\ninclusion: 123\n---\n# Content\n";
let diagnostics = validate_steering(content);
let kiro_001: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-001")
.collect();
assert_eq!(kiro_001.len(), 1);
assert_eq!(kiro_001[0].level, DiagnosticLevel::Error);
}
#[test]
fn test_kiro_002_auto_empty_name_flagged() {
let content = "---\ninclusion: auto\nname: \"\"\ndescription: test desc\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_002: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-002")
.collect();
assert_eq!(kiro_002.len(), 1);
assert!(kiro_002[0].message.contains("name"));
}
#[test]
fn test_kiro_002_auto_null_name_flagged() {
let content = "---\ninclusion: auto\nname: null\ndescription: test desc\n---\n# Steering\n";
let diagnostics = validate_steering(content);
let kiro_002: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-002")
.collect();
assert_eq!(kiro_002.len(), 1);
assert!(kiro_002[0].message.contains("name"));
}
#[test]
fn test_kiro_001_case_sensitive() {
let content = "---\ninclusion: ALWAYS\n---\n# Content\n";
let diagnostics = validate_steering(content);
let kiro_001: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-001")
.collect();
assert_eq!(kiro_001.len(), 1);
}
#[test]
fn test_kiro_003_non_string_pattern_flagged() {
let content = "---\nfileMatchPattern: 123\n---\n# Content\n";
let diagnostics = validate_steering(content);
let kiro_003: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-003")
.collect();
assert_eq!(kiro_003.len(), 1);
assert_eq!(kiro_003[0].level, DiagnosticLevel::Warning);
}
#[test]
fn test_line_col_at_offset_is_one_based() {
assert_eq!(line_col_at_offset("secret", 0), (1, 1));
assert_eq!(line_col_at_offset("x\nsecret", 2), (2, 1));
}
#[test]
fn test_kiro_003_empty_string_pattern() {
let content = "---\nfileMatchPattern: \"\"\n---\n# Content\n";
let diagnostics = validate_steering(content);
let kiro_003: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-003")
.collect();
assert!(kiro_003.is_empty());
}
#[test]
fn test_frontmatter_only_no_body_not_empty() {
let content = "---\ninclusion: always\n---\n";
let diagnostics = validate_steering(content);
let kiro_004: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KIRO-004")
.collect();
assert!(kiro_004.is_empty());
}
#[test]
fn test_kiro_005_frontmatter_only_body() {
let content = "---\ninclusion: always\n---\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().any(|d| d.rule == "KIRO-005"));
}
#[test]
fn test_kiro_006_secrets_detected() {
let content = "---\ninclusion: always\n---\nAPI_KEY=hardcodedsecret123\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().any(|d| d.rule == "KIRO-006"));
}
#[test]
fn test_kiro_006_scans_past_template_values() {
let content =
"---\ninclusion: always\n---\nTOKEN=${ENV_TOKEN}\npassword=plaintextsecret123\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().any(|d| d.rule == "KIRO-006"));
}
#[test]
fn test_kiro_006_ignores_plain_prose_secret_label() {
let content = "---\ninclusion: always\n---\nSecret: guidelines\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().all(|d| d.rule != "KIRO-006"));
}
#[test]
fn test_kiro_006_detects_identifier_style_secret_keys() {
let content = "---\ninclusion: always\n---\nclient_secret: hardcodedsecret123\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().any(|d| d.rule == "KIRO-006"));
}
#[test]
fn test_kiro_007_file_match_pattern_without_file_match_mode() {
let content = "---\ninclusion: always\nfileMatchPattern: \"**/*.md\"\n---\n# body\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().any(|d| d.rule == "KIRO-007"));
}
#[test]
fn test_kiro_008_unknown_frontmatter_field() {
let content = "---\ninclusion: always\ninclusions: true\n---\n# body\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().any(|d| d.rule == "KIRO-008"));
}
#[test]
fn test_kiro_009_missing_inline_file_reference() {
let content = "---\ninclusion: always\n---\nUse #[[file:docs/missing.md]]\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().any(|d| d.rule == "KIRO-009"));
}
#[test]
fn test_kiro_009_skips_absolute_inline_file_reference() {
let content = "---\ninclusion: always\n---\nUse #[[file:/etc/passwd]]\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().all(|d| d.rule != "KIRO-009"));
}
#[test]
fn test_kiro_009_skips_parent_dir_traversal_inline_file_reference() {
let content = "---\ninclusion: always\n---\nUse #[[file:../../secrets.txt]]\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().all(|d| d.rule != "KIRO-009"));
}
#[test]
fn test_kiro_010_missing_inclusion() {
let content = "---\nname: test\n---\n# Steering\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().any(|d| d.rule == "KIRO-010"));
}
#[test]
fn test_kiro_010_inclusion_present_no_diagnostic() {
let content = "---\ninclusion: always\n---\n# Steering\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().all(|d| d.rule != "KIRO-010"));
}
#[test]
fn test_kiro_011_excessively_long_doc() {
let body = "x".repeat(51_000);
let content = format!("---\ninclusion: always\n---\n{}\n", body);
let diagnostics = validate_steering(&content);
assert!(diagnostics.iter().any(|d| d.rule == "KIRO-011"));
}
#[test]
fn test_kiro_011_normal_length_no_diagnostic() {
let content = "---\ninclusion: always\n---\n# Short doc\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().all(|d| d.rule != "KIRO-011"));
}
#[test]
fn test_kiro_006_all_template_values_no_fire() {
let content = "---\ninclusion: always\n---\n# Config\napi_key= ${API_KEY}\ntoken= $(get_token)\npassword= {{VAULT_PW}}\nsecret= <from-env>\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().all(|d| d.rule != "KIRO-006"));
}
#[test]
fn test_kiro_013_single_inclusion_no_diagnostic() {
let content = "---\ninclusion: always\n---\n# Steering\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().all(|d| d.rule != "KIRO-013"));
}
#[test]
fn test_kiro_013_registered_in_metadata() {
let v = KiroSteeringValidator;
let meta = v.metadata();
assert!(meta.rule_ids.contains(&"KIRO-013"));
}
#[test]
fn test_kiro_014_no_heading_in_body() {
let content = "---\ninclusion: always\n---\nJust plain text without any heading.\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().any(|d| d.rule == "KIRO-014"));
}
#[test]
fn test_kiro_014_has_heading_no_diagnostic() {
let content = "---\ninclusion: always\n---\n# Heading\nSome content.\n";
let diagnostics = validate_steering(content);
assert!(diagnostics.iter().all(|d| d.rule != "KIRO-014"));
}
#[test]
fn test_metadata() {
let v = KiroSteeringValidator;
let meta = v.metadata();
assert_eq!(meta.name, "KiroSteeringValidator");
assert_eq!(
meta.rule_ids,
&[
"KIRO-001", "KIRO-002", "KIRO-003", "KIRO-004", "KIRO-005", "KIRO-006", "KIRO-007",
"KIRO-008", "KIRO-009", "KIRO-010", "KIRO-011", "KIRO-012", "KIRO-013", "KIRO-014",
]
);
}
}