diff --git a/CHANGELOG.md b/CHANGELOG.md index 307e10ee2..57a2656dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`.clinerules/*.txt` file support**: agnix now detects and validates `.clinerules/*.txt` files as `ClineRulesFolder` file type. Rules CLN-001 through CLN-004 now apply to `.txt` files in addition to `.md` files, matching Cline's actual behavior. + ### Fixed - **CUR-016 environment.json validation rewritten**: Schema now matches the current Cursor Cloud Agent spec - `install` is required, `terminals` is optional (was previously required), and `build` (with `dockerfile` and `context`) and `update` fields are now validated. Snapshot-based approach replaced with field-level structural validation. Includes 12 new unit tests. - **CUR-001 to CUR-016 verified_on dates updated**: All 16 Cursor rules re-verified against current Cursor documentation on 2026-02-26. - **spec-baselines.json expanded**: Baseline entries added for CUR-007 through CUR-016 to enable cross-version regression detection. - ### Changed - **Unified design system**: Import shared design tokens from agent-sh/design-system. Switch from Outfit to Inter font. Add font preconnect hints to Docusaurus config. Keeps teal accent and light/dark mode. - **GitHub Copilot rule revalidation (COP-001 to COP-018)**: Refreshed evidence links and guidance for current custom-agent docs, added strict type checks for `infer` (boolean only), and aligned COP schema coverage for custom-agent keys (`name`, `disable-model-invocation`, `user-invocable`, `metadata`) with updated fixtures and generated rule docs (#567). diff --git a/SPEC.md b/SPEC.md index c58968662..a318fc646 100644 --- a/SPEC.md +++ b/SPEC.md @@ -20,7 +20,7 @@ | References | @imports | 4 | | GitHub Copilot | .github/copilot-instructions.md, .github/instructions/*.instructions.md, .github/agents/*.agent.md, .github/prompts/*.prompt.md, .github/hooks/hooks.json, .github/workflows/copilot-setup-steps.yml | 17 | | Cursor Project Rules | .cursor/rules/*.mdc, .cursorrules, .cursor/hooks.json, .cursor/agents/**/*.md, .cursor/environment.json | 16 | -| Cline | .clinerules, .clinerules/*.md | 4 | +| Cline | .clinerules, .clinerules/*.md, .clinerules/*.txt | 4 | | OpenCode | opencode.json | 8 | | Gemini CLI | GEMINI.md, GEMINI.local.md, .gemini/settings.json (hooks), gemini-extension.json (extensions), .geminiignore | 9 | | Codex CLI | .codex/config.toml | 7 | diff --git a/crates/agnix-core/benches/validation.rs b/crates/agnix-core/benches/validation.rs index d5439b09b..12380c1ad 100644 --- a/crates/agnix-core/benches/validation.rs +++ b/crates/agnix-core/benches/validation.rs @@ -561,6 +561,14 @@ fn bench_is_instruction_file(c: &mut Criterion) { ("agents_md", Path::new("AGENTS.md").to_path_buf()), ("gemini_md", Path::new("gemini.md").to_path_buf()), ("clinerules", Path::new(".clinerules").to_path_buf()), + ( + "clinerules_folder_md", + Path::new(".clinerules/rules.md").to_path_buf(), + ), + ( + "clinerules_folder_txt", + Path::new(".clinerules/rules.txt").to_path_buf(), + ), // Directory-based matches ( "cursor_mdc", diff --git a/crates/agnix-core/src/file_types/detection.rs b/crates/agnix-core/src/file_types/detection.rs index 1d1d8ba35..a369a65a8 100644 --- a/crates/agnix-core/src/file_types/detection.rs +++ b/crates/agnix-core/src/file_types/detection.rs @@ -344,8 +344,10 @@ pub fn detect_file_type(path: &Path) -> FileType { } // Roo Code rules (.roo/rules/*.md) name if name.ends_with(".md") && is_under_roo_rules(path) => FileType::RooRules, - // Cline rules folder (.clinerules/*.md) - name if name.ends_with(".md") && parent == Some(".clinerules") => { + // Cline rules folder (.clinerules/*.md, .clinerules/*.txt) + name if (name.ends_with(".md") || name.ends_with(".txt")) + && parent == Some(".clinerules") => + { FileType::ClineRulesFolder } // Windsurf rules (.windsurf/rules/**/*.md) @@ -832,6 +834,34 @@ mod tests { ); } + #[test] + fn detect_cline_rules_folder_txt() { + assert_eq!( + detect_file_type(Path::new(".clinerules/custom.txt")), + FileType::ClineRulesFolder + ); + assert_eq!( + detect_file_type(Path::new(".clinerules/01-coding.txt")), + FileType::ClineRulesFolder + ); + } + + #[test] + fn detect_cline_rules_folder_non_md_txt_rejected() { + assert_ne!( + detect_file_type(Path::new(".clinerules/config.json")), + FileType::ClineRulesFolder + ); + assert_ne!( + detect_file_type(Path::new(".clinerules/config.yaml")), + FileType::ClineRulesFolder + ); + assert_ne!( + detect_file_type(Path::new(".clinerules/config.toml")), + FileType::ClineRulesFolder + ); + } + #[test] fn detect_opencode_config() { assert_eq!( diff --git a/crates/agnix-core/src/file_types/types.rs b/crates/agnix-core/src/file_types/types.rs index ca1f0f381..73cc83410 100644 --- a/crates/agnix-core/src/file_types/types.rs +++ b/crates/agnix-core/src/file_types/types.rs @@ -52,7 +52,7 @@ pub enum FileType { CursorRulesLegacy, /// Cline rules single file (.clinerules) ClineRules, - /// Cline rules folder files (.clinerules/*.md) + /// Cline rules folder files (.clinerules/*.md, .clinerules/*.txt) ClineRulesFolder, /// OpenCode configuration (opencode.json) OpenCodeConfig, diff --git a/crates/agnix-core/src/rules/cline.rs b/crates/agnix-core/src/rules/cline.rs index 7c2e754b8..faf81a1c8 100644 --- a/crates/agnix-core/src/rules/cline.rs +++ b/crates/agnix-core/src/rules/cline.rs @@ -1,9 +1,10 @@ -//! Cline rules validation rules (CLN-001 to CLN-003) +//! Cline rules validation rules (CLN-001 to CLN-004) //! //! Validates: //! - CLN-001: Empty clinerules file (HIGH) - files must have content //! - CLN-002: Invalid paths glob in clinerules (HIGH) - glob patterns must be valid //! - CLN-003: Unknown frontmatter key in clinerules (MEDIUM) - only `paths` is recognized +//! - CLN-004: Scalar paths in clinerules (HIGH) - must be array, not scalar use crate::{ FileType, @@ -61,7 +62,7 @@ impl Validator for ClineValidator { // CLN-001: Empty clinerules file (ERROR) if config.is_rule_enabled("CLN-001") { if is_folder { - // For folder .md files, check body after frontmatter if present + // For folder files (.md/.txt), check body after frontmatter if present if let Some(parsed) = parse_frontmatter(content) { // Only check body emptiness when frontmatter parsed successfully; // parse errors (e.g. missing closing ---) produce empty body by default @@ -108,7 +109,7 @@ impl Validator for ClineValidator { } } - // CLN-002 and CLN-003 only apply to folder .md files (they have frontmatter) + // CLN-002, CLN-003, and CLN-004 only apply to folder files (.md/.txt) (they have frontmatter) if !is_folder { return diagnostics; } @@ -244,6 +245,15 @@ mod tests { validator.validate(Path::new(".clinerules/typescript.md"), content, config) } + fn validate_folder_txt(content: &str) -> Vec { + let validator = ClineValidator; + validator.validate( + Path::new(".clinerules/python.txt"), + content, + &LintConfig::default(), + ) + } + // ===== CLN-001: Empty Clinerules File ===== #[test] @@ -592,4 +602,78 @@ unknownKey: value FileType::ClineRulesFolder ); } + + // ===== .txt file validation (mirrors .md tests) ===== + + #[test] + fn test_cln_001_empty_txt_file() { + let diagnostics = validate_folder_txt(""); + let cln_001: Vec<_> = diagnostics.iter().filter(|d| d.rule == "CLN-001").collect(); + assert_eq!(cln_001.len(), 1); + assert_eq!(cln_001[0].level, DiagnosticLevel::Error); + assert!(cln_001[0].message.contains("empty")); + } + + #[test] + fn test_cln_001_valid_txt_file() { + let content = "---\npaths:\n - \"**/*.py\"\n---\n# Python Rules\n\nFollow PEP 8.\n"; + let diagnostics = validate_folder_txt(content); + let cln_001: Vec<_> = diagnostics.iter().filter(|d| d.rule == "CLN-001").collect(); + assert!(cln_001.is_empty()); + } + + #[test] + fn test_cln_002_bad_glob_in_txt() { + let content = "---\npaths:\n - \"[unclosed\"\n---\n# Instructions\n"; + let diagnostics = validate_folder_txt(content); + let cln_002: Vec<_> = diagnostics.iter().filter(|d| d.rule == "CLN-002").collect(); + assert_eq!(cln_002.len(), 1); + assert_eq!(cln_002[0].level, DiagnosticLevel::Error); + assert!(cln_002[0].message.contains("Invalid glob pattern")); + } + + #[test] + fn test_cln_003_unknown_keys_in_txt() { + let content = "---\npaths:\n - \"**/*.ts\"\nunknownKey: value\nanotherBadKey: 123\n---\n# Instructions\n"; + let diagnostics = validate_folder_txt(content); + let cln_003: Vec<_> = diagnostics.iter().filter(|d| d.rule == "CLN-003").collect(); + assert_eq!(cln_003.len(), 2); + assert_eq!(cln_003[0].level, DiagnosticLevel::Warning); + assert!(cln_003.iter().any(|d| d.message.contains("unknownKey"))); + assert!(cln_003.iter().any(|d| d.message.contains("anotherBadKey"))); + assert!( + cln_003.iter().all(|d| d.has_fixes()), + "All unknown key diagnostics should include deletion fixes" + ); + assert!(cln_003.iter().all(|d| !d.fixes[0].safe)); + } + + #[test] + fn test_cln_004_scalar_paths_in_txt() { + let content = "---\npaths: \"**/*.ts\"\n---\n# Instructions\n"; + let diagnostics = validate_folder_txt(content); + let cln_004: Vec<_> = diagnostics.iter().filter(|d| d.rule == "CLN-004").collect(); + assert_eq!(cln_004.len(), 1); + assert_eq!(cln_004[0].level, DiagnosticLevel::Error); + assert!(cln_004[0].message.contains("scalar")); + assert!(cln_004[0].has_fixes(), "CLN-004 should have an auto-fix"); + assert!(cln_004[0].fixes[0].safe, "CLN-004 fix should be safe"); + assert!( + cln_004[0].fixes[0].replacement.contains("- \"**/*.ts\""), + "Fix should convert scalar to array format, got: {}", + cln_004[0].fixes[0].replacement + ); + } + + #[test] + fn test_valid_txt_no_diagnostics() { + let content = + "---\npaths:\n - \"**/*.py\"\n---\n# Python Guidelines\n\nAlways use type hints.\n"; + let diagnostics = validate_folder_txt(content); + assert!( + diagnostics.is_empty(), + "Expected no diagnostics for valid .txt file, got: {:?}", + diagnostics + ); + } } diff --git a/crates/agnix-core/src/schemas/cline.rs b/crates/agnix-core/src/schemas/cline.rs index 9835f0e9c..eaecb448f 100644 --- a/crates/agnix-core/src/schemas/cline.rs +++ b/crates/agnix-core/src/schemas/cline.rs @@ -2,7 +2,7 @@ //! //! Provides parsing and validation for: //! - `.clinerules` single file (plain text, no frontmatter) -//! - `.clinerules/*.md` folder files (optional `paths` frontmatter) +//! - `.clinerules/*.md` and `.clinerules/*.txt` folder files (optional `paths` frontmatter) //! //! Folder files support YAML frontmatter with a `paths` field //! containing glob patterns for scoped rule application. @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; -/// Known valid keys for .clinerules/*.md frontmatter +/// Known valid keys for .clinerules folder file frontmatter const KNOWN_KEYS: &[&str] = &["paths"]; /// Paths field can be a single string (scalar) or an array of strings. @@ -46,7 +46,7 @@ impl PathsField { } } -/// Frontmatter schema for Cline .clinerules/*.md files +/// Frontmatter schema for Cline .clinerules folder files #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ClineRuleSchema { /// Glob patterns specifying which files this rule applies to @@ -93,7 +93,7 @@ pub struct GlobValidation { pub error: Option, } -/// Parse frontmatter from a Cline .clinerules/*.md file +/// Parse frontmatter from a Cline .clinerules folder file /// /// Returns parsed frontmatter if present, or None if no frontmatter exists. pub fn parse_frontmatter(content: &str) -> Option { diff --git a/crates/agnix-wasm/src/lib.rs b/crates/agnix-wasm/src/lib.rs index 69bbbb602..484ac5e14 100644 --- a/crates/agnix-wasm/src/lib.rs +++ b/crates/agnix-wasm/src/lib.rs @@ -149,6 +149,8 @@ pub fn get_supported_file_types() -> JsValue { (".github/copilot-instructions.md", "Copilot"), ("GEMINI.md", "GeminiMd"), (".clinerules", "ClineRules"), + (".clinerules/example.md", "ClineRulesFolder"), + (".clinerules/example.txt", "ClineRulesFolder"), ("CODEX.md", "Codex"), (".opencode/instructions.md", "OpenCode"), ("mcp.json", "Mcp"), diff --git a/knowledge-base/VALIDATION-RULES.md b/knowledge-base/VALIDATION-RULES.md index 78b6a1d95..b0582b19f 100644 --- a/knowledge-base/VALIDATION-RULES.md +++ b/knowledge-base/VALIDATION-RULES.md @@ -1322,21 +1322,21 @@ Rules with an empty `applies_to` object (`{}`) apply universally. ### CLN-002 [HIGH] Invalid Paths Glob in Cline Rules -**Requirement**: `paths` field in `.clinerules/*.md` frontmatter MUST contain valid glob patterns +**Requirement**: `paths` field in `.clinerules/*.md` and `.clinerules/*.txt` frontmatter MUST contain valid glob patterns **Detection**: Parse YAML frontmatter, extract `paths` field, validate each glob pattern **Fix**: No auto-fix (glob patterns must be manually corrected) **Source**: docs.cline.bot/improving-your-workflow/cline-rules ### CLN-003 [MEDIUM] Unknown Frontmatter Key in Cline Rules -**Requirement**: Frontmatter in `.clinerules/*.md` files SHOULD only use documented keys (`paths`) +**Requirement**: Frontmatter in `.clinerules/*.md` and `.clinerules/*.txt` files SHOULD only use documented keys (`paths`) **Detection**: Parse YAML frontmatter, check all keys against allowlist **Fix**: [AUTO-FIX unsafe] Remove unknown frontmatter keys **Source**: docs.cline.bot/improving-your-workflow/cline-rules ### CLN-004 [HIGH] Scalar Paths in Cline Rules -**Requirement**: `paths` field in `.clinerules/*.md` frontmatter MUST be a YAML array, not a scalar string +**Requirement**: `paths` field in `.clinerules/*.md` and `.clinerules/*.txt` frontmatter MUST be a YAML array, not a scalar string **Detection**: Parse YAML frontmatter, check if `paths` is a scalar string (Cline silently ignores scalar values) **Fix**: [AUTO-FIX safe] Convert scalar paths to array format **Source**: docs.cline.bot/features/cline-rules diff --git a/tests/fixtures/cline-invalid/.clinerules/bad-glob.txt b/tests/fixtures/cline-invalid/.clinerules/bad-glob.txt new file mode 100644 index 000000000..748355d0f --- /dev/null +++ b/tests/fixtures/cline-invalid/.clinerules/bad-glob.txt @@ -0,0 +1,7 @@ +--- +paths: + - "[unclosed" +--- +# Bad Glob Test + +This file has an invalid glob pattern. diff --git a/tests/fixtures/cline-invalid/.clinerules/scalar-paths.txt b/tests/fixtures/cline-invalid/.clinerules/scalar-paths.txt new file mode 100644 index 000000000..08677e0f9 --- /dev/null +++ b/tests/fixtures/cline-invalid/.clinerules/scalar-paths.txt @@ -0,0 +1,6 @@ +--- +paths: "**/*.ts" +--- +# Scalar Paths Test + +This file uses scalar paths which Cline ignores. diff --git a/tests/fixtures/cline-invalid/.clinerules/unknown-keys.txt b/tests/fixtures/cline-invalid/.clinerules/unknown-keys.txt new file mode 100644 index 000000000..8d3bf9cc4 --- /dev/null +++ b/tests/fixtures/cline-invalid/.clinerules/unknown-keys.txt @@ -0,0 +1,9 @@ +--- +paths: + - "**/*.ts" +unknownKey: value +anotherBadKey: 123 +--- +# Unknown Keys Test + +This file has unknown frontmatter keys. diff --git a/tests/fixtures/cline/.clinerules/03-python.txt b/tests/fixtures/cline/.clinerules/03-python.txt new file mode 100644 index 000000000..ed6f36ae2 --- /dev/null +++ b/tests/fixtures/cline/.clinerules/03-python.txt @@ -0,0 +1,13 @@ +--- +paths: + - "**/*.py" +--- +# Python Rules + +Follow PEP 8 style guidelines. + +## Guidelines + +- Use type hints for function signatures +- Prefer f-strings over format() +- Use pathlib for file system operations