Skip to content
Merged
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 8 additions & 0 deletions crates/agnix-core/benches/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 32 additions & 2 deletions crates/agnix-core/src/file_types/detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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!(
Expand Down
2 changes: 1 addition & 1 deletion crates/agnix-core/src/file_types/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
90 changes: 87 additions & 3 deletions crates/agnix-core/src/rules/cline.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -244,6 +245,15 @@ mod tests {
validator.validate(Path::new(".clinerules/typescript.md"), content, config)
}

fn validate_folder_txt(content: &str) -> Vec<Diagnostic> {
let validator = ClineValidator;
validator.validate(
Path::new(".clinerules/python.txt"),
content,
&LintConfig::default(),
)
}

// ===== CLN-001: Empty Clinerules File =====

#[test]
Expand Down Expand Up @@ -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
);
}
}
8 changes: 4 additions & 4 deletions crates/agnix-core/src/schemas/cline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
//!
//! 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.

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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -93,7 +93,7 @@ pub struct GlobValidation {
pub error: Option<String>,
}

/// 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<ParsedClineFrontmatter> {
Expand Down
2 changes: 2 additions & 0 deletions crates/agnix-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
6 changes: 3 additions & 3 deletions knowledge-base/VALIDATION-RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1322,21 +1322,21 @@ Rules with an empty `applies_to` object (`{}`) apply universally.

<a id="cln-002"></a>
### 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

<a id="cln-003"></a>
### 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

<a id="cln-004"></a>
### 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
Expand Down
7 changes: 7 additions & 0 deletions tests/fixtures/cline-invalid/.clinerules/bad-glob.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
paths:
- "[unclosed"
---
# Bad Glob Test

This file has an invalid glob pattern.
6 changes: 6 additions & 0 deletions tests/fixtures/cline-invalid/.clinerules/scalar-paths.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
paths: "**/*.ts"
---
# Scalar Paths Test

This file uses scalar paths which Cline ignores.
9 changes: 9 additions & 0 deletions tests/fixtures/cline-invalid/.clinerules/unknown-keys.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
paths:
- "**/*.ts"
unknownKey: value
anotherBadKey: 123
---
# Unknown Keys Test

This file has unknown frontmatter keys.
13 changes: 13 additions & 0 deletions tests/fixtures/cline/.clinerules/03-python.txt
Original file line number Diff line number Diff line change
@@ -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
Loading