diff --git a/.claude/flow.json b/.claude/flow.json new file mode 100644 index 000000000..52366f05f --- /dev/null +++ b/.claude/flow.json @@ -0,0 +1,27 @@ +{ + "version": "1.0.0", + "task": { + "id": "output-style-validator", + "source": "manual", + "title": "PR2: output-style validator (post-#745 follow-on)" + }, + "workflow": { + "id": "output-style-validator", + "status": "active", + "currentPhase": "setup", + "createdAt": "2026-04-22T00:00:00Z", + "updatedAt": "2026-04-22T00:00:00Z" + }, + "git": { + "branch": "feat/output-style-validator", + "baseBranch": "main", + "baseSha": "3c4a3a9", + "mainRepoPath": "C:/Users/avife/agent-sh/agnix", + "isWorktree": true + }, + "context": { + "description": "Add output-style validator for Claude Code output-style files (.claude/output-styles/*.md) - new FileType in agnix-core. Follow-on from triage of issue #745 which merged as PR #756. The keep-coding-instructions field was surfaced during #745 triage.", + "relatedIssue": "#745", + "relatedMergedPR": "#756" + } +} diff --git a/.exploration-report-pr2.md b/.exploration-report-pr2.md new file mode 100644 index 000000000..20cd1706d --- /dev/null +++ b/.exploration-report-pr2.md @@ -0,0 +1,78 @@ +# PR2 Exploration Report - Output-Style Validator + +## A. Reference template + +Picked: ClaudeRule (FileType::ClaudeRule, validator ClaudeRulesValidator, schema ClaudeRuleSchema) + +Why: +- Same tool family (Claude Code), same path pattern (.claude//*.md) +- Only 2 rules (CC-MEM-011, CC-MEM-012) - simplest possible CC-family validator +- Self-contained YAML frontmatter schema using serde_yaml + heuristic unknown-key detection +- Valid and invalid fixtures exist under tests/fixtures/valid/claude-rules/ and invalid/claude-rules/ +- No extra crate dependencies beyond serde_yaml +# Step map + +1. types.rs - add ClaudeOutputStyle variant after ClaudeRule (line 42) +2. detection.rs - add output-styles arm after ClaudeRule arm (lines 381-386) +3. schemas/output_style.rs - new file, mirror claude_rules.rs +4. schemas/mod.rs - add pub mod output_style at line 33 +5. rules/output_style.rs - new file, mirror claude_rules.rs +6. rules/mod.rs - add pub mod output_style at line 32 +7. registry.rs - EXPECTED_BUILTIN_COUNT 73->74, new factory fn, ClaudeProvider entry, test array 42->43 +8. knowledge-base/rules.json - increment total_rules, add N CC-OS entries +9. crates/agnix-rules/rules.json - cp from knowledge-base/rules.json +10. knowledge-base/VALIDATION-RULES.md - new section, footer count +11. tests/fixtures/ - valid and invalid output-style fixtures +12. output_style.rs tests - co-located #[cfg(test)] mod tests + +# Additional files + +- config/rule_filter.rs: add CC-OS- branch to is_category_enabled() +- config.rs: add output_styles: bool to RuleConfig +- rule_parity.rs: add CC-OS- to valid_prefixes, claude-output-styles to valid_categories, fixture mapping +# B. Naming confirmations + +CC-OS- prefix: Zero existing rules, no collision confirmed. +FileType::ClaudeOutputStyle: Matches convention (ClaudeRule, ClaudeMd). +OutputStyleSchema: Consistent with ClaudeRuleSchema. +OutputStyleValidator: Consistent with ClaudeRulesValidator. + +# C. Implementation gotchas + +1. EXPECTED_BUILTIN_COUNT sentinel (registry.rs line 401): +1 per validator entry. 73->74. Test array [FileType; 42] -> [FileType; 43]. + +2. Exhaustive match arrays in types.rs tests: three functions list every FileType variant in typed arrays; all must include ClaudeOutputStyle or compile fails. + +3. rule_parity.rs valid_prefixes (line 167): CC-OS- must be listed or test_all_rules_implemented fails. + +4. rule_parity.rs valid_categories (line 509): test_rules_json_integrity panics on unknown category; add claude-output-styles. + +5. rule_parity.rs infer_fixture_coverage (line 280): test_fixture_coverage_exists fails without claude-output-styles -> directory mapping. + +6. i18n locale files: Avoid rust_i18n::t!() for CC-OS rules; use inline strings to skip editing en.yml, es.yml, zh-CN.yml. + +7. Hyphenated YAML key keep-coding-instructions: Cannot use #[serde(rename)] with hyphens. Extract manually from serde_yaml::Mapping as done for paths in claude_rules.rs. + +8. VALIDATION-RULES.md anchor: Parity test looks for (lowercase ID). + +9. Autofix footer count: stays at 126 if no CC-OS rules have autofix:true. + +10. Clippy Rust 1.95 -D warnings (CI stricter than local 1.92): avoid unnecessary_sort_by (use .sort()), needless_borrows_for_generic_args, unnecessary_literal_unwrap (use .expect()), gate unix-only imports with #[cfg(unix)]. + +11. total_rules in rules.json: test_rules_json_integrity asserts total_rules == rules.len(); always sync. + +12. test_sum_of_category_providers_equals_expected (registry.rs line 1947): Must match EXPECTED_BUILTIN_COUNT; ClaudeProvider entry and constant must change together. + +13. Safe execution order: schema -> validator -> file_types -> registry -> config.rs -> rule_filter.rs -> rules.json (both copies) -> VALIDATION-RULES.md -> fixtures -> rule_parity.rs. Run cargo test --workspace before adding fixtures. + +# D. Proposed rule set (CC-OS-001 through CC-OS-005) + +| ID | Name | Severity | Normative | Checks | Bad example | +|---|---|---|---|---|---| +| CC-OS-001 | Missing Output Style Description | LOW | SHOULD | description absent or empty; needed for /config picker label | name without description in frontmatter | +| CC-OS-002 | Invalid keep-coding-instructions Type | HIGH | MUST | keep-coding-instructions present but not YAML boolean; e.g. string yes, number 1, null | keep-coding-instructions: yes (quoted string) | +| CC-OS-003 | Unknown Output Style Frontmatter Key | MEDIUM | SHOULD | Top-level key not in {name, description, keep-coding-instructions}; signals typo or wrong file type | paths: - src/** (a rules field, not output-style) | +| CC-OS-004 | Empty Output Style Body | MEDIUM | SHOULD | Body after closing --- (or entire file if no frontmatter) is blank/whitespace-only; dead config | frontmatter with blank body | +| CC-OS-005 | Output Style Name Too Long | LOW | SHOULD | name value exceeds 64 chars; truncates in /config picker UI | name: this-is-a-very-long-style-name-exceeding-64-chars | + +Omissions: name format constraints not documented; name not required (defaults to filename); CC-OS-006 (name+filename) over-specified for optional field. diff --git a/CHANGELOG.md b/CHANGELOG.md index 316a8e9e9..f0051fcd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Output-style validator (CC-OS-001..005)** - new validator for `.claude/output-styles/*.md` files, surfaced during the Claude Code v2.1.117 triage (#745). The output-style frontmatter spec was added in v2.1.94 with the `keep-coding-instructions` field. New rules: CC-OS-001 missing description (LOW), CC-OS-002 invalid `keep-coding-instructions` type (HIGH), CC-OS-003 unknown frontmatter key (MEDIUM), CC-OS-004 empty body (MEDIUM), CC-OS-005 name exceeds 64 chars (LOW). All non-autofix. New `FileType::ClaudeOutputStyle`, `OutputStyleSchema`, `OutputStyleValidator`. Total rules: 399 -> 404. - **Daily tool-release watcher** (`.github/workflows/tool-release-watch.yml`, `scripts/check-tool-releases.sh`, `scripts/glm-extract.js`) - polls every supported tool's release feed daily at 7am UTC and opens a per-tool issue when a new release is detected. Supports four source types via `.github/tool-release-baselines.json`: GitHub releases (claude-code, codex, opencode, copilot via `microsoft/vscode-copilot-chat`, cline, roo-code, gemini-cli), JSON update endpoint (cursor's `api2.cursor.sh` stable channel), HTML scrape with regex (kiro, windsurf), and RSS slug (amp). Per-tool `notes_extractor` selects between LLM-extracted markdown via GLM (`glm-5` by default; activates only when `GLM_API_KEY` repo secret is set), `` CDATA parse, or stub link. Issues are deduped by `tool-release:` label so re-runs comment on existing open issues. `workflow_dispatch` accepts `tool` filter and `update_baselines: true` toggle. - **Playground state in URL** - playground configs are now shareable by link via `?file=`, `?tool=`, and `?code=` querystring params (URL-safe base64 for UTF-8 content, debounced writes, capped at 6 KB to stay URL-safe). diff --git a/crates/agnix-cli/tests/cli_integration.rs b/crates/agnix-cli/tests/cli_integration.rs index 8a49dc0dc..b4150694f 100644 --- a/crates/agnix-cli/tests/cli_integration.rs +++ b/crates/agnix-cli/tests/cli_integration.rs @@ -187,15 +187,16 @@ fn test_format_sarif_has_all_rules() { // Use threshold range to avoid brittleness when rules are added/removed, // while still catching major regressions (missing rules) or explosions. - // As of writing, there are ~385 rules documented in VALIDATION-RULES.md. + // As of writing, there are ~405 rules documented in VALIDATION-RULES.md; + // the upper bound has ~10% headroom against accidental explosion. assert!( rules.len() >= 70, "Expected at least 70 validation rules, found {} (possible rule registration bug)", rules.len() ); assert!( - rules.len() <= 400, - "Expected at most 400 validation rules, found {} (unexpected rule explosion)", + rules.len() <= 450, + "Expected at most 450 validation rules, found {} (unexpected rule explosion)", rules.len() ); diff --git a/crates/agnix-cli/tests/rule_parity.rs b/crates/agnix-cli/tests/rule_parity.rs index 8301fe963..2a8203f8d 100644 --- a/crates/agnix-cli/tests/rule_parity.rs +++ b/crates/agnix-cli/tests/rule_parity.rs @@ -165,10 +165,10 @@ fn extract_implemented_rule_ids() -> BTreeSet { // Known rule ID prefixes to filter out false positives let valid_prefixes = [ - "AS-", "CC-SK-", "CC-HK-", "CC-AG-", "CC-MEM-", "CC-PL-", "AGM-", "MCP-", "COP-", "CUR-", - "CLN-", "CDX-", "OC-", "GM-", "XML-", "REF-", "PE-", "XP-", "VER-", "WS-", "CR-SK-", - "CL-SK-", "CP-SK-", "CX-SK-", "OC-SK-", "WS-SK-", "KR-SK-", "KR-AG-", "KR-HK-", "KR-PW-", - "KR-MCP-", "KIRO-", "AMP-SK-", "AMP-", "RC-SK-", "ROO-", + "AS-", "CC-SK-", "CC-HK-", "CC-AG-", "CC-MEM-", "CC-OS-", "CC-PL-", "AGM-", "MCP-", "COP-", + "CUR-", "CLN-", "CDX-", "OC-", "GM-", "XML-", "REF-", "PE-", "XP-", "VER-", "WS-", + "CR-SK-", "CL-SK-", "CP-SK-", "CX-SK-", "OC-SK-", "WS-SK-", "KR-SK-", "KR-AG-", "KR-HK-", + "KR-PW-", "KR-MCP-", "KIRO-", "AMP-SK-", "AMP-", "RC-SK-", "ROO-", ]; fn extract_from_file( @@ -289,6 +289,10 @@ fn infer_fixture_coverage(rules: &[RuleEntry]) -> HashMap> { ("claude-hooks", vec!["valid/hooks", "invalid/hooks"]), ("claude-agents", vec!["valid/agents", "invalid/agents"]), ("claude-memory", vec!["valid/memory", "invalid/memory"]), + ( + "claude-output-styles", + vec!["valid/output-styles", "invalid/output-styles"], + ), ("claude-plugins", vec!["valid/plugins", "invalid/plugins"]), ("agents-md", vec!["agents_md"]), ("mcp", vec!["mcp"]), @@ -512,6 +516,7 @@ fn test_rules_json_integrity() { "claude-hooks", "claude-agents", "claude-memory", + "claude-output-styles", "agents-md", "claude-plugins", "mcp", diff --git a/crates/agnix-core/src/config.rs b/crates/agnix-core/src/config.rs index 8033f6800..a6b07116e 100644 --- a/crates/agnix-core/src/config.rs +++ b/crates/agnix-core/src/config.rs @@ -529,6 +529,11 @@ pub struct RuleConfig { #[schemars(description = "Enable Claude Code memory validation rules (CC-MEM-*)")] pub memory: bool, + /// Enable output-styles validation (CC-OS-*) + #[serde(default = "default_true")] + #[schemars(description = "Enable Claude Code output-style validation rules (CC-OS-*)")] + pub output_styles: bool, + /// Enable plugins validation (CC-PL-*) #[serde(default = "default_true")] #[schemars(description = "Enable Claude Code plugins validation rules (CC-PL-*)")] @@ -661,6 +666,7 @@ impl Default for RuleConfig { hooks: true, agents: true, memory: true, + output_styles: true, plugins: true, xml: true, mcp: true, diff --git a/crates/agnix-core/src/config/rule_filter.rs b/crates/agnix-core/src/config/rule_filter.rs index 7ddabd3c3..ee60c29ba 100644 --- a/crates/agnix-core/src/config/rule_filter.rs +++ b/crates/agnix-core/src/config/rule_filter.rs @@ -98,6 +98,7 @@ impl<'a> DefaultRuleFilter<'a> { s if s.starts_with("CC-HK-") => self.rules.hooks, s if s.starts_with("CC-AG-") => self.rules.agents, s if s.starts_with("CC-MEM-") => self.rules.memory, + s if s.starts_with("CC-OS-") => self.rules.output_styles, s if s.starts_with("CC-PL-") => self.rules.plugins, s if s.starts_with("XML-") => self.rules.xml, s if s.starts_with("MCP-") => self.rules.mcp, diff --git a/crates/agnix-core/src/file_types/detection.rs b/crates/agnix-core/src/file_types/detection.rs index 0dc51ff99..1eea8f453 100644 --- a/crates/agnix-core/src/file_types/detection.rs +++ b/crates/agnix-core/src/file_types/detection.rs @@ -384,6 +384,13 @@ pub fn detect_file_type(path: &Path) -> FileType { { FileType::ClaudeRule } + // Claude Code output styles (.claude/output-styles/*.md) + name if name.ends_with(".md") + && parent == Some("output-styles") + && grandparent == Some(".claude") => + { + FileType::ClaudeOutputStyle + } // Cursor project rules (.cursor/rules/**/*.md and .mdc) name if (name.ends_with(".md") || name.ends_with(".mdc")) && is_under_cursor_rules(path) => @@ -787,6 +794,23 @@ mod tests { ); } + #[test] + fn detect_claude_output_style() { + assert_eq!( + detect_file_type(Path::new(".claude/output-styles/concise.md")), + FileType::ClaudeOutputStyle + ); + } + + #[test] + fn detect_claude_output_style_not_rules_dir() { + // .claude/rules/*.md must remain ClaudeRule, NOT ClaudeOutputStyle. + assert_eq!( + detect_file_type(Path::new(".claude/rules/custom.md")), + FileType::ClaudeRule + ); + } + #[test] fn detect_amp_check() { assert_eq!( diff --git a/crates/agnix-core/src/file_types/types.rs b/crates/agnix-core/src/file_types/types.rs index ba85cb998..3696ecb21 100644 --- a/crates/agnix-core/src/file_types/types.rs +++ b/crates/agnix-core/src/file_types/types.rs @@ -40,6 +40,8 @@ pub enum FileType { CopilotHooks, /// Claude Code rules (.claude/rules/*.md) ClaudeRule, + /// Claude Code output style files (.claude/output-styles/*.md) + ClaudeOutputStyle, /// Cursor project rules (.cursor/rules/*.md, .cursor/rules/*.mdc, including nested dirs) CursorRule, /// Cursor hooks configuration (.cursor/hooks.json) @@ -141,6 +143,7 @@ impl fmt::Display for FileType { FileType::CopilotPrompt => "CopilotPrompt", FileType::CopilotHooks => "CopilotHooks", FileType::ClaudeRule => "ClaudeRule", + FileType::ClaudeOutputStyle => "ClaudeOutputStyle", FileType::CursorRule => "CursorRule", FileType::CursorHooks => "CursorHooks", FileType::CursorAgent => "CursorAgent", @@ -196,6 +199,7 @@ mod tests { (FileType::CopilotPrompt, "CopilotPrompt"), (FileType::CopilotHooks, "CopilotHooks"), (FileType::ClaudeRule, "ClaudeRule"), + (FileType::ClaudeOutputStyle, "ClaudeOutputStyle"), (FileType::CursorRule, "CursorRule"), (FileType::CursorHooks, "CursorHooks"), (FileType::CursorAgent, "CursorAgent"), @@ -250,6 +254,7 @@ mod tests { FileType::CopilotPrompt, FileType::CopilotHooks, FileType::ClaudeRule, + FileType::ClaudeOutputStyle, FileType::CursorRule, FileType::CursorHooks, FileType::CursorAgent, @@ -318,6 +323,7 @@ mod tests { FileType::CopilotPrompt, FileType::CopilotHooks, FileType::ClaudeRule, + FileType::ClaudeOutputStyle, FileType::CursorRule, FileType::CursorHooks, FileType::CursorAgent, diff --git a/crates/agnix-core/src/registry.rs b/crates/agnix-core/src/registry.rs index ae068f72d..011399512 100644 --- a/crates/agnix-core/src/registry.rs +++ b/crates/agnix-core/src/registry.rs @@ -398,7 +398,7 @@ impl ValidatorRegistryBuilder { /// /// Used by `BuiltinProvider` (via `debug_assert_eq!`) and tests to catch /// accidental additions or removals without updating all providers. -const EXPECTED_BUILTIN_COUNT: usize = 73; +const EXPECTED_BUILTIN_COUNT: usize = 74; // -- Category providers ----------------------------------------------------- // @@ -482,6 +482,11 @@ impl ValidatorProvider for ClaudeProvider { Some("ClaudeRulesValidator"), claude_rules_validator, ), + ( + FileType::ClaudeOutputStyle, + Some("OutputStyleValidator"), + output_style_validator, + ), ] } } @@ -882,6 +887,10 @@ fn claude_rules_validator() -> Box { Box::new(crate::rules::claude_rules::ClaudeRulesValidator) } +fn output_style_validator() -> Box { + Box::new(crate::rules::output_style::OutputStyleValidator) +} + fn cursor_validator() -> Box { Box::new(crate::rules::cursor::CursorValidator) } @@ -1355,7 +1364,7 @@ mod tests { #[test] fn every_validatable_file_type_has_at_least_one_validator() { - let validatable_types: [FileType; 42] = [ + let validatable_types: [FileType; 43] = [ FileType::Skill, FileType::ClaudeMd, FileType::Agent, @@ -1369,6 +1378,7 @@ mod tests { FileType::CopilotPrompt, FileType::CopilotHooks, FileType::ClaudeRule, + FileType::ClaudeOutputStyle, FileType::CursorRule, FileType::CursorHooks, FileType::CursorAgent, @@ -1417,6 +1427,7 @@ mod tests { | FileType::CopilotPrompt | FileType::CopilotHooks | FileType::ClaudeRule + | FileType::ClaudeOutputStyle | FileType::CursorRule | FileType::CursorHooks | FileType::CursorAgent @@ -1903,7 +1914,7 @@ mod tests { #[test] fn claude_provider_count() { - assert_eq!(ClaudeProvider.named_validators().len(), 11); + assert_eq!(ClaudeProvider.named_validators().len(), 12); } #[test] diff --git a/crates/agnix-core/src/rules/mod.rs b/crates/agnix-core/src/rules/mod.rs index 572468e58..178c50495 100644 --- a/crates/agnix-core/src/rules/mod.rs +++ b/crates/agnix-core/src/rules/mod.rs @@ -24,6 +24,7 @@ pub mod kiro_power; pub mod kiro_steering; pub mod mcp; pub mod opencode; +pub mod output_style; pub mod per_client_skill; pub mod plugin; pub mod project_level; diff --git a/crates/agnix-core/src/rules/output_style.rs b/crates/agnix-core/src/rules/output_style.rs new file mode 100644 index 000000000..4766e033f --- /dev/null +++ b/crates/agnix-core/src/rules/output_style.rs @@ -0,0 +1,509 @@ +//! `.claude/output-styles/*.md` frontmatter validation rules +//! +//! Validates output-style files added in Claude Code v2.1.94. Spec: +//! https://code.claude.com/docs/en/output-styles (verified 2026-04-22) +//! +//! - CC-OS-001 (LOW) : description absent or whitespace-only +//! - CC-OS-002 (HIGH) : keep-coding-instructions present but not a YAML bool +//! - CC-OS-003 (MEDIUM) : unknown top-level frontmatter key +//! - CC-OS-004 (MEDIUM) : body after closing `---` is empty/whitespace-only +//! - CC-OS-005 (LOW) : `name` value exceeds 64 characters +//! - CC-OS-006 (HIGH) : invalid output-style frontmatter syntax (YAML parse error) +//! +//! All rules are non-autofix. + +use crate::{ + config::LintConfig, + diagnostics::Diagnostic, + rules::{Validator, ValidatorMetadata}, + schemas::output_style::parse_frontmatter, +}; +use std::path::Path; + +const RULE_IDS: &[&str] = &[ + "CC-OS-001", + "CC-OS-002", + "CC-OS-003", + "CC-OS-004", + "CC-OS-005", + "CC-OS-006", +]; + +const NAME_MAX_LEN: usize = 64; + +pub struct OutputStyleValidator; + +/// Human-readable type name for a `serde_yaml::Value`. +fn yaml_type_name(v: &serde_yaml::Value) -> &'static str { + match v { + serde_yaml::Value::Null => "null", + serde_yaml::Value::Bool(_) => "boolean", + serde_yaml::Value::Number(_) => "number", + serde_yaml::Value::String(_) => "string", + serde_yaml::Value::Sequence(_) => "sequence", + serde_yaml::Value::Mapping(_) => "mapping", + serde_yaml::Value::Tagged(_) => "tagged", + } +} + +impl Validator for OutputStyleValidator { + fn metadata(&self) -> ValidatorMetadata { + ValidatorMetadata { + name: self.name(), + rule_ids: RULE_IDS, + } + } + + fn validate(&self, path: &Path, content: &str, config: &LintConfig) -> Vec { + let mut diagnostics = Vec::new(); + + // Path guard: only validate .claude/output-styles/*.md files + let parent = path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()); + let grandparent = path + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()); + + if parent != Some("output-styles") || grandparent != Some(".claude") { + return diagnostics; + } + + // Parse frontmatter; if absent there is nothing to validate. + let parsed = match parse_frontmatter(content) { + Some(p) => p, + None => return diagnostics, + }; + + // CC-OS-006: surface YAML parse errors before per-rule checks. + if let Some(ref parse_error) = parsed.parse_error { + if config.is_rule_enabled("CC-OS-006") { + diagnostics.push( + Diagnostic::error( + path.to_path_buf(), + parsed.start_line, + 0, + "CC-OS-006", + format!("Invalid output-style frontmatter: {}", parse_error), + ) + .with_suggestion( + "Fix the YAML syntax (close frontmatter with a line containing only `---`, escape special characters, etc).", + ), + ); + } + return diagnostics; + } + + let schema = match parsed.schema.as_ref() { + Some(s) => s, + None => return diagnostics, + }; + + // CC-OS-001: description absent or whitespace-only (LOW) + if config.is_rule_enabled("CC-OS-001") { + let missing = match schema.description.as_deref() { + None => true, + Some(s) => s.trim().is_empty(), + }; + if missing { + diagnostics.push( + Diagnostic::info( + path.to_path_buf(), + parsed.start_line, + 0, + "CC-OS-001", + "Output style is missing a `description` field" + .to_string(), + ) + .with_suggestion( + "Add `description: ` so the /config picker can label this style.", + ), + ); + } + } + + // CC-OS-002: keep-coding-instructions must be a YAML boolean (HIGH) + if config.is_rule_enabled("CC-OS-002") { + if let Some(v) = schema.keep_coding_instructions.as_ref() { + if v.as_bool().is_none() { + diagnostics.push( + Diagnostic::error( + path.to_path_buf(), + parsed.start_line, + 0, + "CC-OS-002", + format!( + "`keep-coding-instructions` must be a boolean (true/false); got {}", + yaml_type_name(v) + ), + ) + .with_suggestion( + "Use `keep-coding-instructions: true` or `keep-coding-instructions: false`.", + ), + ); + } + } + } + + // CC-OS-003: unknown top-level frontmatter key (MEDIUM) + if config.is_rule_enabled("CC-OS-003") { + for unknown in &parsed.unknown_keys { + diagnostics.push( + Diagnostic::warning( + path.to_path_buf(), + unknown.line, + unknown.column, + "CC-OS-003", + format!("Output style frontmatter has unknown key '{}'", unknown.key), + ) + .with_suggestion("Allowed keys: name, description, keep-coding-instructions."), + ); + } + } + + // CC-OS-004: empty/whitespace-only body (MEDIUM) + if config.is_rule_enabled("CC-OS-004") && parsed.body_is_empty { + diagnostics.push( + Diagnostic::warning( + path.to_path_buf(), + parsed.start_line, + 0, + "CC-OS-004", + "Output style has no body content - the file is a dead config".to_string(), + ) + .with_suggestion( + "Add the system-prompt instructions for Claude Code below the closing `---`.", + ), + ); + } + + // CC-OS-005: name exceeds 64 chars (LOW) + if config.is_rule_enabled("CC-OS-005") { + if let Some(name) = schema.name.as_deref() { + if name.chars().count() > NAME_MAX_LEN { + diagnostics.push( + Diagnostic::info( + path.to_path_buf(), + parsed.start_line, + 0, + "CC-OS-005", + format!( + "Output style `name` is {} characters; recommended maximum is {}", + name.chars().count(), + NAME_MAX_LEN + ), + ) + .with_suggestion( + "Shorten the name so it fits in the /config picker without truncation.", + ), + ); + } + } + } + + diagnostics + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::LintConfig; + use crate::diagnostics::DiagnosticLevel; + + fn validate(content: &str) -> Vec { + OutputStyleValidator.validate( + Path::new(".claude/output-styles/concise.md"), + content, + &LintConfig::default(), + ) + } + + fn validate_with_config(content: &str, config: &LintConfig) -> Vec { + OutputStyleValidator.validate( + Path::new(".claude/output-styles/concise.md"), + content, + config, + ) + } + + // ===== Path guard ===== + + #[test] + fn test_wrong_path_no_diagnostics() { + let validator = OutputStyleValidator; + let content = "---\nfoo: bar\n---\n"; + // Wrong subdirectory - .claude/rules/ not .claude/output-styles/ + let diagnostics = validator.validate( + Path::new(".claude/rules/concise.md"), + content, + &LintConfig::default(), + ); + assert!(diagnostics.is_empty()); + + // Wrong grandparent + let diagnostics = validator.validate( + Path::new("some/output-styles/concise.md"), + content, + &LintConfig::default(), + ); + assert!(diagnostics.is_empty()); + } + + // ===== CC-OS-001 ===== + + #[test] + fn test_cc_os_001_missing_description() { + let content = "---\nname: Concise\n---\nBody"; + let diagnostics = validate(content); + let hits: Vec<_> = diagnostics + .iter() + .filter(|d| d.rule == "CC-OS-001") + .collect(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].level, DiagnosticLevel::Info); + } + + #[test] + fn test_cc_os_001_whitespace_description() { + let content = "---\nname: Concise\ndescription: \" \"\n---\nBody"; + let diagnostics = validate(content); + assert!(diagnostics.iter().any(|d| d.rule == "CC-OS-001")); + } + + #[test] + fn test_cc_os_001_present_description() { + let content = "---\nname: Concise\ndescription: Short replies\n---\nBody"; + let diagnostics = validate(content); + assert!(!diagnostics.iter().any(|d| d.rule == "CC-OS-001")); + } + + // ===== CC-OS-002 ===== + + #[test] + fn test_cc_os_002_string_value() { + let content = "---\nname: X\ndescription: y\nkeep-coding-instructions: \"yes\"\n---\nBody"; + let diagnostics = validate(content); + let hits: Vec<_> = diagnostics + .iter() + .filter(|d| d.rule == "CC-OS-002") + .collect(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].level, DiagnosticLevel::Error); + assert!(hits[0].message.contains("string")); + } + + #[test] + fn test_cc_os_002_number_value() { + let content = "---\nname: X\ndescription: y\nkeep-coding-instructions: 1\n---\nBody"; + let diagnostics = validate(content); + assert!(diagnostics.iter().any(|d| d.rule == "CC-OS-002")); + } + + #[test] + fn test_cc_os_002_null_value() { + let content = "---\nname: X\ndescription: y\nkeep-coding-instructions: null\n---\nBody"; + let diagnostics = validate(content); + assert!(diagnostics.iter().any(|d| d.rule == "CC-OS-002")); + } + + #[test] + fn test_cc_os_002_bool_value_ok() { + let content = "---\nname: X\ndescription: y\nkeep-coding-instructions: true\n---\nBody"; + let diagnostics = validate(content); + assert!(!diagnostics.iter().any(|d| d.rule == "CC-OS-002")); + } + + #[test] + fn test_cc_os_002_no_autofix() { + let content = "---\nname: X\ndescription: y\nkeep-coding-instructions: \"yes\"\n---\nBody"; + let diagnostics = validate(content); + let hits: Vec<_> = diagnostics + .iter() + .filter(|d| d.rule == "CC-OS-002") + .collect(); + assert_eq!(hits.len(), 1); + assert!(!hits[0].has_fixes()); + } + + // ===== CC-OS-003 ===== + + #[test] + fn test_cc_os_003_unknown_key() { + let content = "---\nname: X\ndescription: y\nfoo: bar\n---\nBody"; + let diagnostics = validate(content); + let hits: Vec<_> = diagnostics + .iter() + .filter(|d| d.rule == "CC-OS-003") + .collect(); + assert_eq!(hits.len(), 1); + assert!(hits[0].message.contains("foo")); + } + + #[test] + fn test_cc_os_003_known_keys_ok() { + let content = "---\nname: X\ndescription: y\nkeep-coding-instructions: false\n---\nBody"; + let diagnostics = validate(content); + assert!(!diagnostics.iter().any(|d| d.rule == "CC-OS-003")); + } + + #[test] + fn test_cc_os_003_no_autofix() { + let content = "---\nname: X\ndescription: y\nfoo: bar\n---\nBody"; + let diagnostics = validate(content); + let hits: Vec<_> = diagnostics + .iter() + .filter(|d| d.rule == "CC-OS-003") + .collect(); + assert_eq!(hits.len(), 1); + assert!(!hits[0].has_fixes(), "CC-OS-003 must not auto-fix"); + } + + // ===== CC-OS-004 ===== + + #[test] + fn test_cc_os_004_empty_body() { + let content = "---\nname: X\ndescription: y\n---\n\n \n"; + let diagnostics = validate(content); + assert!(diagnostics.iter().any(|d| d.rule == "CC-OS-004")); + } + + #[test] + fn test_cc_os_004_no_body_lines_at_all() { + let content = "---\nname: X\ndescription: y\n---\n"; + let diagnostics = validate(content); + assert!(diagnostics.iter().any(|d| d.rule == "CC-OS-004")); + } + + #[test] + fn test_cc_os_004_non_empty_body_ok() { + let content = "---\nname: X\ndescription: y\n---\nReal instructions."; + let diagnostics = validate(content); + assert!(!diagnostics.iter().any(|d| d.rule == "CC-OS-004")); + } + + // ===== CC-OS-005 ===== + + #[test] + fn test_cc_os_005_long_name() { + let long = "a".repeat(65); + let content = format!("---\nname: {}\ndescription: y\n---\nBody", long); + let diagnostics = validate(&content); + let hits: Vec<_> = diagnostics + .iter() + .filter(|d| d.rule == "CC-OS-005") + .collect(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].level, DiagnosticLevel::Info); + } + + #[test] + fn test_cc_os_005_exact_length_ok() { + let exactly = "a".repeat(64); + let content = format!("---\nname: {}\ndescription: y\n---\nBody", exactly); + let diagnostics = validate(&content); + assert!(!diagnostics.iter().any(|d| d.rule == "CC-OS-005")); + } + + #[test] + fn test_cc_os_005_short_name_ok() { + let content = "---\nname: Concise\ndescription: y\n---\nBody"; + let diagnostics = validate(content); + assert!(!diagnostics.iter().any(|d| d.rule == "CC-OS-005")); + } + + // ===== CC-OS-006 ===== + + #[test] + fn test_cc_os_006_unclosed_frontmatter() { + let content = "---\nname: X\ndescription: y"; + let diagnostics = validate(content); + let hits: Vec<_> = diagnostics + .iter() + .filter(|d| d.rule == "CC-OS-006") + .collect(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].level, DiagnosticLevel::Error); + assert!(hits[0].message.contains("missing closing ---")); + } + + #[test] + fn test_cc_os_006_invalid_yaml() { + // Tab-indented sequence is invalid YAML + let content = "---\nname: X\nkey:\n\t- bad\n---\nBody"; + let diagnostics = validate(content); + assert!(diagnostics.iter().any(|d| d.rule == "CC-OS-006")); + } + + #[test] + fn test_cc_os_006_does_not_use_cc_os_002() { + // Parse-error path must use CC-OS-006, not the overloaded CC-OS-002. + let content = "---\nname: X"; + let diagnostics = validate(content); + assert!(!diagnostics.iter().any(|d| d.rule == "CC-OS-002")); + assert!(diagnostics.iter().any(|d| d.rule == "CC-OS-006")); + } + + // ===== Per-rule disable ===== + + #[test] + fn test_config_disabled_specific_rules() { + let rules = [ + "CC-OS-001", + "CC-OS-002", + "CC-OS-003", + "CC-OS-004", + "CC-OS-005", + "CC-OS-006", + ]; + + // Triggers CC-OS-001..005 (long name, non-bool keep, unknown key, empty body) + let long = "a".repeat(65); + let content_005 = format!( + "---\nname: {}\nkeep-coding-instructions: \"yes\"\nfoo: bar\n---\n \n", + long + ); + // Triggers CC-OS-006 only (unclosed frontmatter) + let content_006 = "---\nname: x".to_string(); + + for rule in rules { + let mut config = LintConfig::default(); + config.rules_mut().disabled_rules = vec![rule.to_string()]; + + let content = if rule == "CC-OS-006" { + &content_006 + } else { + &content_005 + }; + let diagnostics = validate_with_config(content, &config); + assert!( + !diagnostics.iter().any(|d| d.rule == rule), + "Rule {} should be disabled but was emitted", + rule + ); + } + } + + // ===== Combined / valid ===== + + #[test] + fn test_valid_output_style_no_issues() { + let content = "---\nname: Concise\ndescription: Short replies\nkeep-coding-instructions: true\n---\nBe brief and direct."; + let diagnostics = validate(content); + assert!( + diagnostics.is_empty(), + "Expected no diagnostics, got: {:?}", + diagnostics + ); + } + + #[test] + fn test_no_frontmatter_no_diagnostics() { + let content = "# Just markdown\n\nNo frontmatter here."; + let diagnostics = validate(content); + assert!(diagnostics.is_empty()); + } +} diff --git a/crates/agnix-core/src/schemas/mod.rs b/crates/agnix-core/src/schemas/mod.rs index 671379698..ef7fa4c85 100644 --- a/crates/agnix-core/src/schemas/mod.rs +++ b/crates/agnix-core/src/schemas/mod.rs @@ -27,6 +27,7 @@ pub mod kiro_mcp; pub mod kiro_power; pub mod mcp; pub mod opencode; +pub mod output_style; pub mod plugin; pub mod prompt; pub mod roo; diff --git a/crates/agnix-core/src/schemas/output_style.rs b/crates/agnix-core/src/schemas/output_style.rs new file mode 100644 index 000000000..95f2125a0 --- /dev/null +++ b/crates/agnix-core/src/schemas/output_style.rs @@ -0,0 +1,283 @@ +//! `.claude/output-styles/*.md` frontmatter schema helpers +//! +//! Provides parsing for Claude Code output-style files. Output styles let users +//! customise the tone/format Claude Code responds in. Frontmatter has 3 known +//! optional fields: `name`, `description`, `keep-coding-instructions`. +//! +//! Spec: https://code.claude.com/docs/en/output-styles (verified 2026-04-22) + +use serde::{Deserialize, Deserializer}; +use std::collections::HashSet; + +/// Known valid keys for `.claude/output-styles/*.md` frontmatter +pub(crate) const KNOWN_KEYS: &[&str] = &["name", "description", "keep-coding-instructions"]; + +/// Frontmatter schema for Claude output-style files +/// +/// `keep_coding_instructions` is kept as a raw `serde_yaml::Value` so the +/// validator can distinguish "missing" from "present but wrong type" +/// (CC-OS-002 requires rejecting non-bool values like `"yes"`, `1`, `null`). +/// The hyphenated YAML key is mapped to a Rust-snake-case field via +/// `#[serde(rename)]`. +/// +/// A custom deserializer wraps the value in `Some(_)` even when YAML is `null`, +/// so CC-OS-002 can detect `keep-coding-instructions: null` (which serde would +/// otherwise collapse into `None`, indistinguishable from "field absent"). +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct OutputStyleSchema { + pub name: Option, + pub description: Option, + #[serde( + rename = "keep-coding-instructions", + deserialize_with = "deserialize_present_value" + )] + pub keep_coding_instructions: Option, +} + +/// Deserialize a value while preserving `null` as `Some(Value::Null)` (absent fields +/// get `None` via `#[serde(default)]`, never via this function). +fn deserialize_present_value<'de, D>(d: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let v = serde_yaml::Value::deserialize(d)?; + Ok(Some(v)) +} + +/// Result of parsing output-style frontmatter +#[derive(Debug, Clone)] +pub struct ParsedOutputStyleFrontmatter { + /// The parsed schema (if valid YAML) + pub schema: Option, + /// Raw frontmatter string (between --- markers) + #[allow(dead_code)] // parsed but not yet consumed by validators + pub raw: String, + /// Line number where frontmatter starts (1-indexed) + pub start_line: usize, + /// Line number where frontmatter ends (1-indexed) + #[allow(dead_code)] // parsed but not yet consumed by validators + pub end_line: usize, + /// Unknown keys found in frontmatter + pub unknown_keys: Vec, + /// Parse error if YAML is invalid + pub parse_error: Option, + /// True when the body after the closing `---` is empty/whitespace-only. + /// Pre-computed during parse so CC-OS-004 doesn't re-scan the file. + pub body_is_empty: bool, +} + +/// An unknown key found in frontmatter +#[derive(Debug, Clone)] +pub struct UnknownKey { + pub key: String, + pub line: usize, + pub column: usize, +} + +/// Parse frontmatter from a `.claude/output-styles/*.md` file. +/// +/// Returns parsed frontmatter if a `---` opening delimiter is present, or +/// `None` if no frontmatter exists. +pub fn parse_frontmatter(content: &str) -> Option { + if !content.starts_with("---") { + return None; + } + + let lines: Vec<&str> = content.lines().collect(); + if lines.is_empty() { + return None; + } + + // Find closing --- + let mut end_idx = None; + for (i, line) in lines.iter().enumerate().skip(1) { + if line.trim() == "---" { + end_idx = Some(i); + break; + } + } + + // Opening --- but no closing ---: surface as parse error. + if end_idx.is_none() { + let frontmatter_lines: Vec<&str> = lines[1..].to_vec(); + let raw = frontmatter_lines.join("\n"); + + return Some(ParsedOutputStyleFrontmatter { + schema: None, + raw, + start_line: 1, + end_line: lines.len(), + unknown_keys: Vec::new(), + parse_error: Some("missing closing ---".to_string()), + body_is_empty: true, + }); + } + + let end_idx = end_idx.unwrap(); + + // Extract frontmatter content (between --- markers) + let frontmatter_lines: Vec<&str> = lines[1..end_idx].to_vec(); + let raw = frontmatter_lines.join("\n"); + + // Body lives after the closing --- + let body_lines: &[&str] = if end_idx + 1 < lines.len() { + &lines[end_idx + 1..] + } else { + &[] + }; + let body_is_empty = body_lines.iter().all(|l| l.trim().is_empty()); + + // Detect unknown keys via line scanning (independent of YAML parse success). + let unknown_keys = find_unknown_keys(&raw, 2); // line 1 is the opening --- + + // Try to parse the schema fields from YAML. + let (schema, parse_error) = parse_schema(&raw); + + Some(ParsedOutputStyleFrontmatter { + schema, + raw, + start_line: 1, + end_line: end_idx + 1, + unknown_keys, + parse_error, + body_is_empty, + }) +} + +/// Parse the schema from raw YAML frontmatter. +/// +/// Uses `serde_yaml` deserialization directly into [`OutputStyleSchema`]. +/// The hyphenated YAML key `keep-coding-instructions` maps to +/// `keep_coding_instructions` via `#[serde(rename)]` on the struct field. +/// `keep_coding_instructions` deserializes as `serde_yaml::Value` so the +/// validator can detect non-bool values (string `"yes"`, number `1`, `null`) +/// in CC-OS-002 — using `Option` would silently coerce or fail the +/// whole struct. +fn parse_schema(raw: &str) -> (Option, Option) { + if raw.trim().is_empty() { + return (Some(OutputStyleSchema::default()), None); + } + + match serde_yaml::from_str::(raw) { + Ok(schema) => (Some(schema), None), + Err(e) => (None, Some(e.to_string())), + } +} + +/// Find unknown keys in frontmatter YAML by line scanning. +/// +/// Top-level keys in YAML frontmatter are not indented; this matches that +/// heuristic so nested mapping keys are not flagged. +fn find_unknown_keys(yaml: &str, start_line: usize) -> Vec { + let known: HashSet<&str> = KNOWN_KEYS.iter().copied().collect(); + let mut unknown = Vec::new(); + + for (i, line) in yaml.lines().enumerate() { + if line.starts_with(' ') || line.starts_with('\t') { + continue; + } + if line.trim_start().starts_with('#') { + continue; + } + if let Some(colon_idx) = line.find(':') { + let key_raw = &line[..colon_idx]; + let key = key_raw.trim().trim_matches(|c| c == '\'' || c == '\"'); + if !key.is_empty() && !known.contains(key) { + unknown.push(UnknownKey { + key: key.to_string(), + line: start_line + i, + column: key_raw.len() - key_raw.trim_start().len(), + }); + } + } + } + + unknown +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_no_frontmatter() { + let content = "# Just markdown without frontmatter"; + let result = parse_frontmatter(content); + assert!(result.is_none()); + } + + #[test] + fn test_parse_empty_frontmatter() { + let content = "---\n---\nbody"; + let result = parse_frontmatter(content).unwrap(); + let schema = result.schema.unwrap(); + assert!(schema.name.is_none()); + assert!(schema.description.is_none()); + assert!(schema.keep_coding_instructions.is_none()); + assert!(result.parse_error.is_none()); + assert!(!result.body_is_empty); + } + + #[test] + fn test_parse_with_keep_coding_instructions_true() { + let content = "---\nname: Concise\ndescription: short\nkeep-coding-instructions: true\n---\nBe brief."; + let result = parse_frontmatter(content).unwrap(); + let schema = result.schema.unwrap(); + assert_eq!(schema.name.as_deref(), Some("Concise")); + assert_eq!(schema.description.as_deref(), Some("short")); + assert_eq!( + schema.keep_coding_instructions, + Some(serde_yaml::Value::Bool(true)) + ); + assert!(result.unknown_keys.is_empty()); + } + + #[test] + fn test_parse_with_keep_coding_instructions_non_bool() { + let content = "---\nname: Concise\nkeep-coding-instructions: \"yes\"\n---\nBody"; + let result = parse_frontmatter(content).unwrap(); + let schema = result.schema.unwrap(); + let v = schema.keep_coding_instructions.expect("present"); + assert!(v.as_bool().is_none(), "value must NOT be bool"); + assert_eq!(v.as_str(), Some("yes")); + } + + #[test] + fn test_detect_unknown_keys() { + let content = "---\nname: X\ndescription: y\nfoo: bar\nalwaysApply: true\n---\nbody"; + let result = parse_frontmatter(content).unwrap(); + assert_eq!(result.unknown_keys.len(), 2); + assert!(result.unknown_keys.iter().any(|k| k.key == "foo")); + assert!(result.unknown_keys.iter().any(|k| k.key == "alwaysApply")); + } + + #[test] + fn test_detect_empty_body() { + let content = "---\nname: X\n---\n \n\n"; + let result = parse_frontmatter(content).unwrap(); + assert!(result.body_is_empty); + } + + #[test] + fn test_detect_non_empty_body() { + let content = "---\nname: X\n---\nReal instructions."; + let result = parse_frontmatter(content).unwrap(); + assert!(!result.body_is_empty); + } + + #[test] + fn test_known_keys_not_flagged() { + let content = "---\nname: X\ndescription: y\nkeep-coding-instructions: false\n---\nbody"; + let result = parse_frontmatter(content).unwrap(); + assert!(result.unknown_keys.is_empty()); + } + + #[test] + fn test_unclosed_frontmatter_is_parse_error() { + let content = "---\nname: X"; + let result = parse_frontmatter(content).unwrap(); + assert!(result.parse_error.is_some()); + assert_eq!(result.parse_error.as_deref(), Some("missing closing ---")); + } +} diff --git a/crates/agnix-core/tests/api_contract.rs b/crates/agnix-core/tests/api_contract.rs index 304d7cb1f..9c7899570 100644 --- a/crates/agnix-core/tests/api_contract.rs +++ b/crates/agnix-core/tests/api_contract.rs @@ -271,6 +271,7 @@ fn file_type_enum_covers_all_variants() { agnix_core::FileType::CopilotPrompt, agnix_core::FileType::CopilotHooks, agnix_core::FileType::ClaudeRule, + agnix_core::FileType::ClaudeOutputStyle, agnix_core::FileType::CursorRule, agnix_core::FileType::CursorHooks, agnix_core::FileType::CursorAgent, @@ -305,7 +306,7 @@ fn file_type_enum_covers_all_variants() { assert_eq!( variants.len(), - 43, + 44, "A new FileType variant may have been added or removed. Please update this test's variant list and the match statement below." ); @@ -324,6 +325,7 @@ fn file_type_enum_covers_all_variants() { agnix_core::FileType::CopilotPrompt => {} agnix_core::FileType::CopilotHooks => {} agnix_core::FileType::ClaudeRule => {} + agnix_core::FileType::ClaudeOutputStyle => {} agnix_core::FileType::CursorRule => {} agnix_core::FileType::CursorHooks => {} agnix_core::FileType::CursorAgent => {} diff --git a/crates/agnix-rules/rules.json b/crates/agnix-rules/rules.json index 7d2ae2f32..fe940327c 100644 --- a/crates/agnix-rules/rules.json +++ b/crates/agnix-rules/rules.json @@ -1,7 +1,7 @@ { "description": "Machine-readable source of truth for all validation rules. When adding a new rule, add it here AND in VALIDATION-RULES.md. CI parity tests enforce sync.", "version": "1.1.0", - "total_rules": 399, + "total_rules": 405, "last_updated": "2026-04-01", "schema": { "evidence": { @@ -2363,6 +2363,168 @@ "good_example": "---\npaths:\n - \"src/**/*.ts\"\n---\n# TypeScript Guidelines\n\nAlways use strict mode.", "bad_example": "---\npaths:\n - \"src/**/*.ts\"\ndescription: \"some rule\"\nalwaysApply: true\n---\n# TypeScript Guidelines\n\nAlways use strict mode." }, + { + "id": "CC-OS-001", + "name": "Output Style Missing Description", + "severity": "LOW", + "category": "claude-output-styles", + "evidence": { + "source_type": "vendor_docs", + "source_urls": [ + "https://code.claude.com/docs/en/output-styles" + ], + "verified_on": "2026-04-22", + "applies_to": { + "tool": "claude-code" + }, + "normative_level": "SHOULD", + "tests": { + "unit": true, + "fixtures": true, + "e2e": false + } + }, + "fix": { + "autofix": false + }, + "good_example": "---\nname: Concise\ndescription: Short, direct replies\n---\nBe brief.", + "bad_example": "---\nname: Concise\n---\nBe brief." + }, + { + "id": "CC-OS-002", + "name": "Output Style Invalid keep-coding-instructions Type", + "severity": "HIGH", + "category": "claude-output-styles", + "evidence": { + "source_type": "vendor_docs", + "source_urls": [ + "https://code.claude.com/docs/en/output-styles" + ], + "verified_on": "2026-04-22", + "applies_to": { + "tool": "claude-code" + }, + "normative_level": "MUST", + "tests": { + "unit": true, + "fixtures": true, + "e2e": false + } + }, + "fix": { + "autofix": false + }, + "good_example": "---\nname: Concise\ndescription: Short replies\nkeep-coding-instructions: true\n---\nBe brief.", + "bad_example": "---\nname: Concise\ndescription: Short replies\nkeep-coding-instructions: \"yes\"\n---\nBe brief." + }, + { + "id": "CC-OS-003", + "name": "Output Style Unknown Frontmatter Key", + "severity": "MEDIUM", + "category": "claude-output-styles", + "evidence": { + "source_type": "vendor_docs", + "source_urls": [ + "https://code.claude.com/docs/en/output-styles" + ], + "verified_on": "2026-04-22", + "applies_to": { + "tool": "claude-code" + }, + "normative_level": "SHOULD", + "tests": { + "unit": true, + "fixtures": true, + "e2e": false + } + }, + "fix": { + "autofix": false + }, + "good_example": "---\nname: Concise\ndescription: Short replies\nkeep-coding-instructions: false\n---\nBe brief.", + "bad_example": "---\nname: Concise\ndescription: Short replies\nalwaysApply: true\n---\nBe brief." + }, + { + "id": "CC-OS-004", + "name": "Output Style Empty Body", + "severity": "MEDIUM", + "category": "claude-output-styles", + "evidence": { + "source_type": "vendor_docs", + "source_urls": [ + "https://code.claude.com/docs/en/output-styles" + ], + "verified_on": "2026-04-22", + "applies_to": { + "tool": "claude-code" + }, + "normative_level": "SHOULD", + "tests": { + "unit": true, + "fixtures": true, + "e2e": false + } + }, + "fix": { + "autofix": false + }, + "good_example": "---\nname: Concise\ndescription: Short replies\n---\nBe brief and direct.", + "bad_example": "---\nname: Concise\ndescription: Short replies\n---\n" + }, + { + "id": "CC-OS-005", + "name": "Output Style Name Exceeds Length", + "severity": "LOW", + "category": "claude-output-styles", + "evidence": { + "source_type": "vendor_docs", + "source_urls": [ + "https://code.claude.com/docs/en/output-styles" + ], + "verified_on": "2026-04-22", + "applies_to": { + "tool": "claude-code" + }, + "normative_level": "SHOULD", + "tests": { + "unit": true, + "fixtures": true, + "e2e": false + } + }, + "fix": { + "autofix": false + }, + "good_example": "---\nname: Concise\ndescription: Short replies\n---\nBe brief.", + "bad_example": "---\nname: A very very very very very very very very very very long output style name\ndescription: Short\n---\nBe brief." + }, + { + "id": "CC-OS-006", + "name": "Invalid Output Style Frontmatter Syntax", + "severity": "HIGH", + "category": "claude-output-styles", + "evidence": { + "source_type": "vendor_docs", + "source_urls": [ + "https://code.claude.com/docs/en/output-styles" + ], + "verified_on": "2026-04-22", + "applies_to": { + "tool": "claude-code" + }, + "normative_level": "MUST", + "tests": { + "unit": true, + "fixtures": false, + "e2e": false + } + }, + "fix": { + "autofix": false + }, + "good_example": "---\nname: Concise\ndescription: Short replies\n---\nBe brief.", + "bad_example": "---\nname: Concise\ndescription: Short replies" + }, { "id": "CC-MEM-014", "name": "CLAUDE.md Exceeds Line Limit", @@ -11032,6 +11194,11 @@ "count": 13, "description": "Claude Code Memory rules" }, + "claude-output-styles": { + "prefix": "CC-OS", + "count": 6, + "description": "Claude Code Output Styles rules" + }, "agents-md": { "prefix": "AGM", "count": 6, diff --git a/knowledge-base/VALIDATION-RULES.md b/knowledge-base/VALIDATION-RULES.md index 27e5a55b6..1d200e419 100644 --- a/knowledge-base/VALIDATION-RULES.md +++ b/knowledge-base/VALIDATION-RULES.md @@ -893,6 +893,54 @@ Rules with an empty `applies_to` object (`{}`) apply universally. --- +## CLAUDE CODE RULES (OUTPUT STYLES) + +Output-style files (`.claude/output-styles/*.md` or `~/.claude/output-styles/*.md`) customise Claude's response tone/format. The `keep-coding-instructions` frontmatter field was added in Claude Code v2.1.94. Frontmatter has 3 known optional fields: `name`, `description`, `keep-coding-instructions`. + + +### CC-OS-001 [LOW] Output Style Missing Description +**Requirement**: `description` SHOULD be present and non-empty +**Detection**: Frontmatter parse - flag if `description` absent or whitespace-only +**Fix**: Manual - add a one-sentence summary +**Source**: code.claude.com/docs/en/output-styles + + +### CC-OS-002 [HIGH] Output Style Invalid keep-coding-instructions Type +**Requirement**: `keep-coding-instructions` MUST be a YAML boolean (`true` or `false`) +**Detection**: Frontmatter parse - reject string `"yes"`, number `1`, `null`, etc. +**Fix**: Manual - use `keep-coding-instructions: true` or `false` +**Source**: code.claude.com/docs/en/output-styles + + +### CC-OS-003 [MEDIUM] Output Style Unknown Frontmatter Key +**Requirement**: Top-level frontmatter keys SHOULD be one of `name`, `description`, `keep-coding-instructions` +**Detection**: Line scan - flag any other top-level key +**Fix**: Manual - remove or rename +**Source**: code.claude.com/docs/en/output-styles + + +### CC-OS-004 [MEDIUM] Output Style Empty Body +**Requirement**: Output styles SHOULD have a non-empty body after the closing `---` +**Detection**: Body lines after frontmatter are all whitespace +**Fix**: Manual - add the system-prompt instructions +**Source**: code.claude.com/docs/en/output-styles + + +### CC-OS-005 [LOW] Output Style Name Exceeds Length +**Requirement**: `name` SHOULD be 64 characters or fewer +**Detection**: Count characters in `name` value +**Fix**: Manual - shorten the name +**Source**: code.claude.com/docs/en/output-styles + + +### CC-OS-006 [HIGH] Invalid Output Style Frontmatter Syntax +**Requirement**: Output-style frontmatter MUST be valid YAML between two `---` delimiters +**Detection**: YAML parse error or unclosed frontmatter +**Fix**: Manual - fix the YAML syntax (close frontmatter, escape special chars, etc.) +**Source**: code.claude.com/docs/en/output-styles + +--- + ## CLAUDE CODE RULES (MEMORY) diff --git a/knowledge-base/rules.json b/knowledge-base/rules.json index 7d2ae2f32..fe940327c 100644 --- a/knowledge-base/rules.json +++ b/knowledge-base/rules.json @@ -1,7 +1,7 @@ { "description": "Machine-readable source of truth for all validation rules. When adding a new rule, add it here AND in VALIDATION-RULES.md. CI parity tests enforce sync.", "version": "1.1.0", - "total_rules": 399, + "total_rules": 405, "last_updated": "2026-04-01", "schema": { "evidence": { @@ -2363,6 +2363,168 @@ "good_example": "---\npaths:\n - \"src/**/*.ts\"\n---\n# TypeScript Guidelines\n\nAlways use strict mode.", "bad_example": "---\npaths:\n - \"src/**/*.ts\"\ndescription: \"some rule\"\nalwaysApply: true\n---\n# TypeScript Guidelines\n\nAlways use strict mode." }, + { + "id": "CC-OS-001", + "name": "Output Style Missing Description", + "severity": "LOW", + "category": "claude-output-styles", + "evidence": { + "source_type": "vendor_docs", + "source_urls": [ + "https://code.claude.com/docs/en/output-styles" + ], + "verified_on": "2026-04-22", + "applies_to": { + "tool": "claude-code" + }, + "normative_level": "SHOULD", + "tests": { + "unit": true, + "fixtures": true, + "e2e": false + } + }, + "fix": { + "autofix": false + }, + "good_example": "---\nname: Concise\ndescription: Short, direct replies\n---\nBe brief.", + "bad_example": "---\nname: Concise\n---\nBe brief." + }, + { + "id": "CC-OS-002", + "name": "Output Style Invalid keep-coding-instructions Type", + "severity": "HIGH", + "category": "claude-output-styles", + "evidence": { + "source_type": "vendor_docs", + "source_urls": [ + "https://code.claude.com/docs/en/output-styles" + ], + "verified_on": "2026-04-22", + "applies_to": { + "tool": "claude-code" + }, + "normative_level": "MUST", + "tests": { + "unit": true, + "fixtures": true, + "e2e": false + } + }, + "fix": { + "autofix": false + }, + "good_example": "---\nname: Concise\ndescription: Short replies\nkeep-coding-instructions: true\n---\nBe brief.", + "bad_example": "---\nname: Concise\ndescription: Short replies\nkeep-coding-instructions: \"yes\"\n---\nBe brief." + }, + { + "id": "CC-OS-003", + "name": "Output Style Unknown Frontmatter Key", + "severity": "MEDIUM", + "category": "claude-output-styles", + "evidence": { + "source_type": "vendor_docs", + "source_urls": [ + "https://code.claude.com/docs/en/output-styles" + ], + "verified_on": "2026-04-22", + "applies_to": { + "tool": "claude-code" + }, + "normative_level": "SHOULD", + "tests": { + "unit": true, + "fixtures": true, + "e2e": false + } + }, + "fix": { + "autofix": false + }, + "good_example": "---\nname: Concise\ndescription: Short replies\nkeep-coding-instructions: false\n---\nBe brief.", + "bad_example": "---\nname: Concise\ndescription: Short replies\nalwaysApply: true\n---\nBe brief." + }, + { + "id": "CC-OS-004", + "name": "Output Style Empty Body", + "severity": "MEDIUM", + "category": "claude-output-styles", + "evidence": { + "source_type": "vendor_docs", + "source_urls": [ + "https://code.claude.com/docs/en/output-styles" + ], + "verified_on": "2026-04-22", + "applies_to": { + "tool": "claude-code" + }, + "normative_level": "SHOULD", + "tests": { + "unit": true, + "fixtures": true, + "e2e": false + } + }, + "fix": { + "autofix": false + }, + "good_example": "---\nname: Concise\ndescription: Short replies\n---\nBe brief and direct.", + "bad_example": "---\nname: Concise\ndescription: Short replies\n---\n" + }, + { + "id": "CC-OS-005", + "name": "Output Style Name Exceeds Length", + "severity": "LOW", + "category": "claude-output-styles", + "evidence": { + "source_type": "vendor_docs", + "source_urls": [ + "https://code.claude.com/docs/en/output-styles" + ], + "verified_on": "2026-04-22", + "applies_to": { + "tool": "claude-code" + }, + "normative_level": "SHOULD", + "tests": { + "unit": true, + "fixtures": true, + "e2e": false + } + }, + "fix": { + "autofix": false + }, + "good_example": "---\nname: Concise\ndescription: Short replies\n---\nBe brief.", + "bad_example": "---\nname: A very very very very very very very very very very long output style name\ndescription: Short\n---\nBe brief." + }, + { + "id": "CC-OS-006", + "name": "Invalid Output Style Frontmatter Syntax", + "severity": "HIGH", + "category": "claude-output-styles", + "evidence": { + "source_type": "vendor_docs", + "source_urls": [ + "https://code.claude.com/docs/en/output-styles" + ], + "verified_on": "2026-04-22", + "applies_to": { + "tool": "claude-code" + }, + "normative_level": "MUST", + "tests": { + "unit": true, + "fixtures": false, + "e2e": false + } + }, + "fix": { + "autofix": false + }, + "good_example": "---\nname: Concise\ndescription: Short replies\n---\nBe brief.", + "bad_example": "---\nname: Concise\ndescription: Short replies" + }, { "id": "CC-MEM-014", "name": "CLAUDE.md Exceeds Line Limit", @@ -11032,6 +11194,11 @@ "count": 13, "description": "Claude Code Memory rules" }, + "claude-output-styles": { + "prefix": "CC-OS", + "count": 6, + "description": "Claude Code Output Styles rules" + }, "agents-md": { "prefix": "AGM", "count": 6, diff --git a/tests/fixtures/invalid/output-styles/.claude/output-styles/empty-body.md b/tests/fixtures/invalid/output-styles/.claude/output-styles/empty-body.md new file mode 100644 index 000000000..710885cd3 --- /dev/null +++ b/tests/fixtures/invalid/output-styles/.claude/output-styles/empty-body.md @@ -0,0 +1,7 @@ +--- +name: Concise +description: Short replies +--- + + + diff --git a/tests/fixtures/invalid/output-styles/.claude/output-styles/long-name.md b/tests/fixtures/invalid/output-styles/.claude/output-styles/long-name.md new file mode 100644 index 000000000..98065f755 --- /dev/null +++ b/tests/fixtures/invalid/output-styles/.claude/output-styles/long-name.md @@ -0,0 +1,5 @@ +--- +name: A very very very very very very very very very very long output style name +description: Short +--- +Be brief. diff --git a/tests/fixtures/invalid/output-styles/.claude/output-styles/missing-description.md b/tests/fixtures/invalid/output-styles/.claude/output-styles/missing-description.md new file mode 100644 index 000000000..368595a3b --- /dev/null +++ b/tests/fixtures/invalid/output-styles/.claude/output-styles/missing-description.md @@ -0,0 +1,4 @@ +--- +name: Concise +--- +Be brief and direct. diff --git a/tests/fixtures/invalid/output-styles/.claude/output-styles/non-bool-keep.md b/tests/fixtures/invalid/output-styles/.claude/output-styles/non-bool-keep.md new file mode 100644 index 000000000..ffd0b2939 --- /dev/null +++ b/tests/fixtures/invalid/output-styles/.claude/output-styles/non-bool-keep.md @@ -0,0 +1,6 @@ +--- +name: Concise +description: Short replies +keep-coding-instructions: "yes" +--- +Be brief. diff --git a/tests/fixtures/invalid/output-styles/.claude/output-styles/unknown-key.md b/tests/fixtures/invalid/output-styles/.claude/output-styles/unknown-key.md new file mode 100644 index 000000000..1df425bfb --- /dev/null +++ b/tests/fixtures/invalid/output-styles/.claude/output-styles/unknown-key.md @@ -0,0 +1,6 @@ +--- +name: Concise +description: Short replies +alwaysApply: true +--- +Be brief. diff --git a/tests/fixtures/valid/output-styles/.claude/output-styles/concise.md b/tests/fixtures/valid/output-styles/.claude/output-styles/concise.md new file mode 100644 index 000000000..6a8f5b661 --- /dev/null +++ b/tests/fixtures/valid/output-styles/.claude/output-styles/concise.md @@ -0,0 +1,6 @@ +--- +name: Concise +description: Short, direct replies with minimal preamble +keep-coding-instructions: true +--- +Be brief and direct. Skip preamble. Show code first, prose second. diff --git a/website/docs/rules/generated/cc-ag-009.md b/website/docs/rules/generated/cc-ag-009.md index 4ed1ddb2e..f1b795aef 100644 --- a/website/docs/rules/generated/cc-ag-009.md +++ b/website/docs/rules/generated/cc-ag-009.md @@ -13,7 +13,7 @@ keywords: ["CC-AG-009", "invalid tool name in tools list", "claude agents", "val - **Category**: `Claude Agents` - **Normative Level**: `MUST` - **Auto-Fix**: `No` -- **Verified On**: `2026-02-07` +- **Verified On**: `2026-04-22` ## Applicability diff --git a/website/docs/rules/generated/cc-ag-010.md b/website/docs/rules/generated/cc-ag-010.md index 64cc77722..7ec580f6d 100644 --- a/website/docs/rules/generated/cc-ag-010.md +++ b/website/docs/rules/generated/cc-ag-010.md @@ -13,7 +13,7 @@ keywords: ["CC-AG-010", "invalid tool name in disallowedtools", "claude agents", - **Category**: `Claude Agents` - **Normative Level**: `MUST` - **Auto-Fix**: `No` -- **Verified On**: `2026-02-07` +- **Verified On**: `2026-04-22` ## Applicability diff --git a/website/docs/rules/generated/cc-ag-011.md b/website/docs/rules/generated/cc-ag-011.md index 675db2aee..13f3c8dce 100644 --- a/website/docs/rules/generated/cc-ag-011.md +++ b/website/docs/rules/generated/cc-ag-011.md @@ -13,7 +13,7 @@ keywords: ["CC-AG-011", "invalid hooks in agent frontmatter", "claude agents", " - **Category**: `Claude Agents` - **Normative Level**: `MUST` - **Auto-Fix**: `No` -- **Verified On**: `2026-02-07` +- **Verified On**: `2026-04-22` ## Applicability diff --git a/website/docs/rules/generated/cc-ag-014.md b/website/docs/rules/generated/cc-ag-014.md index 78cc8fcf3..0d64a37d6 100644 --- a/website/docs/rules/generated/cc-ag-014.md +++ b/website/docs/rules/generated/cc-ag-014.md @@ -13,7 +13,7 @@ keywords: ["CC-AG-014", "invalid effort value", "claude agents", "validation", " - **Category**: `Claude Agents` - **Normative Level**: `MUST` - **Auto-Fix**: `Yes (unsafe)` -- **Verified On**: `2026-03-28` +- **Verified On**: `2026-04-22` ## Applicability diff --git a/website/docs/rules/generated/cc-ag-019.md b/website/docs/rules/generated/cc-ag-019.md index a1f433fc2..1949756df 100644 --- a/website/docs/rules/generated/cc-ag-019.md +++ b/website/docs/rules/generated/cc-ag-019.md @@ -13,7 +13,7 @@ keywords: ["CC-AG-019", "unknown agent frontmatter field", "claude agents", "val - **Category**: `Claude Agents` - **Normative Level**: `SHOULD` - **Auto-Fix**: `Yes (unsafe)` -- **Verified On**: `2026-03-28` +- **Verified On**: `2026-04-22` ## Applicability diff --git a/website/docs/rules/generated/cc-os-001.md b/website/docs/rules/generated/cc-os-001.md new file mode 100644 index 000000000..ec549dd90 --- /dev/null +++ b/website/docs/rules/generated/cc-os-001.md @@ -0,0 +1,55 @@ +--- +id: cc-os-001 +title: "CC-OS-001: Output Style Missing Description" +sidebar_label: "CC-OS-001" +description: "agnix rule CC-OS-001 checks for output style missing description in claude-output-styles files. Severity: LOW. See examples and fix guidance." +keywords: ["CC-OS-001", "output style missing description", "claude-output-styles", "validation", "agnix", "linter"] +--- + +## Summary + +- **Rule ID**: `CC-OS-001` +- **Severity**: `LOW` +- **Category**: `claude-output-styles` +- **Normative Level**: `SHOULD` +- **Auto-Fix**: `No` +- **Verified On**: `2026-04-22` + +## Applicability + +- **Tool**: `claude-code` +- **Version Range**: `unspecified` +- **Spec Revision**: `unspecified` + +## Evidence Sources + +- https://code.claude.com/docs/en/output-styles + +## Test Coverage Metadata + +- Unit tests: `true` +- Fixture tests: `true` +- E2E tests: `false` + +## Examples + +The following examples demonstrate what triggers this rule and how to fix it. + +### Invalid + +```text +--- +name: Concise +--- +Be brief. +``` + +### Valid + +```text +--- +name: Concise +description: Short, direct replies +--- +Be brief. +``` diff --git a/website/docs/rules/generated/cc-os-002.md b/website/docs/rules/generated/cc-os-002.md new file mode 100644 index 000000000..9c47cc98a --- /dev/null +++ b/website/docs/rules/generated/cc-os-002.md @@ -0,0 +1,58 @@ +--- +id: cc-os-002 +title: "CC-OS-002: Output Style Invalid keep-coding-instructions Type" +sidebar_label: "CC-OS-002" +description: "agnix rule CC-OS-002 checks for output style invalid keep-coding-instructions type in claude-output-styles files. Severity: HIGH. See examples and fix guidance." +keywords: ["CC-OS-002", "output style invalid keep-coding-instructions type", "claude-output-styles", "validation", "agnix", "linter"] +--- + +## Summary + +- **Rule ID**: `CC-OS-002` +- **Severity**: `HIGH` +- **Category**: `claude-output-styles` +- **Normative Level**: `MUST` +- **Auto-Fix**: `No` +- **Verified On**: `2026-04-22` + +## Applicability + +- **Tool**: `claude-code` +- **Version Range**: `unspecified` +- **Spec Revision**: `unspecified` + +## Evidence Sources + +- https://code.claude.com/docs/en/output-styles + +## Test Coverage Metadata + +- Unit tests: `true` +- Fixture tests: `true` +- E2E tests: `false` + +## Examples + +The following examples demonstrate what triggers this rule and how to fix it. + +### Invalid + +```text +--- +name: Concise +description: Short replies +keep-coding-instructions: "yes" +--- +Be brief. +``` + +### Valid + +```text +--- +name: Concise +description: Short replies +keep-coding-instructions: true +--- +Be brief. +``` diff --git a/website/docs/rules/generated/cc-os-003.md b/website/docs/rules/generated/cc-os-003.md new file mode 100644 index 000000000..73362c22a --- /dev/null +++ b/website/docs/rules/generated/cc-os-003.md @@ -0,0 +1,58 @@ +--- +id: cc-os-003 +title: "CC-OS-003: Output Style Unknown Frontmatter Key" +sidebar_label: "CC-OS-003" +description: "agnix rule CC-OS-003 checks for output style unknown frontmatter key in claude-output-styles files. Severity: MEDIUM. See examples and fix guidance." +keywords: ["CC-OS-003", "output style unknown frontmatter key", "claude-output-styles", "validation", "agnix", "linter"] +--- + +## Summary + +- **Rule ID**: `CC-OS-003` +- **Severity**: `MEDIUM` +- **Category**: `claude-output-styles` +- **Normative Level**: `SHOULD` +- **Auto-Fix**: `No` +- **Verified On**: `2026-04-22` + +## Applicability + +- **Tool**: `claude-code` +- **Version Range**: `unspecified` +- **Spec Revision**: `unspecified` + +## Evidence Sources + +- https://code.claude.com/docs/en/output-styles + +## Test Coverage Metadata + +- Unit tests: `true` +- Fixture tests: `true` +- E2E tests: `false` + +## Examples + +The following examples demonstrate what triggers this rule and how to fix it. + +### Invalid + +```text +--- +name: Concise +description: Short replies +alwaysApply: true +--- +Be brief. +``` + +### Valid + +```text +--- +name: Concise +description: Short replies +keep-coding-instructions: false +--- +Be brief. +``` diff --git a/website/docs/rules/generated/cc-os-004.md b/website/docs/rules/generated/cc-os-004.md new file mode 100644 index 000000000..ecf70b4f8 --- /dev/null +++ b/website/docs/rules/generated/cc-os-004.md @@ -0,0 +1,55 @@ +--- +id: cc-os-004 +title: "CC-OS-004: Output Style Empty Body - claude-output-styles" +sidebar_label: "CC-OS-004" +description: "agnix rule CC-OS-004 checks for output style empty body in claude-output-styles files. Severity: MEDIUM. See examples and fix guidance." +keywords: ["CC-OS-004", "output style empty body", "claude-output-styles", "validation", "agnix", "linter"] +--- + +## Summary + +- **Rule ID**: `CC-OS-004` +- **Severity**: `MEDIUM` +- **Category**: `claude-output-styles` +- **Normative Level**: `SHOULD` +- **Auto-Fix**: `No` +- **Verified On**: `2026-04-22` + +## Applicability + +- **Tool**: `claude-code` +- **Version Range**: `unspecified` +- **Spec Revision**: `unspecified` + +## Evidence Sources + +- https://code.claude.com/docs/en/output-styles + +## Test Coverage Metadata + +- Unit tests: `true` +- Fixture tests: `true` +- E2E tests: `false` + +## Examples + +The following examples demonstrate what triggers this rule and how to fix it. + +### Invalid + +```text +--- +name: Concise +description: Short replies +--- +``` + +### Valid + +```text +--- +name: Concise +description: Short replies +--- +Be brief and direct. +``` diff --git a/website/docs/rules/generated/cc-os-005.md b/website/docs/rules/generated/cc-os-005.md new file mode 100644 index 000000000..772ac8270 --- /dev/null +++ b/website/docs/rules/generated/cc-os-005.md @@ -0,0 +1,56 @@ +--- +id: cc-os-005 +title: "CC-OS-005: Output Style Name Exceeds Length" +sidebar_label: "CC-OS-005" +description: "agnix rule CC-OS-005 checks for output style name exceeds length in claude-output-styles files. Severity: LOW. See examples and fix guidance." +keywords: ["CC-OS-005", "output style name exceeds length", "claude-output-styles", "validation", "agnix", "linter"] +--- + +## Summary + +- **Rule ID**: `CC-OS-005` +- **Severity**: `LOW` +- **Category**: `claude-output-styles` +- **Normative Level**: `SHOULD` +- **Auto-Fix**: `No` +- **Verified On**: `2026-04-22` + +## Applicability + +- **Tool**: `claude-code` +- **Version Range**: `unspecified` +- **Spec Revision**: `unspecified` + +## Evidence Sources + +- https://code.claude.com/docs/en/output-styles + +## Test Coverage Metadata + +- Unit tests: `true` +- Fixture tests: `true` +- E2E tests: `false` + +## Examples + +The following examples demonstrate what triggers this rule and how to fix it. + +### Invalid + +```text +--- +name: A very very very very very very very very very very long output style name +description: Short +--- +Be brief. +``` + +### Valid + +```text +--- +name: Concise +description: Short replies +--- +Be brief. +``` diff --git a/website/docs/rules/generated/cc-os-006.md b/website/docs/rules/generated/cc-os-006.md new file mode 100644 index 000000000..b89c3a5cd --- /dev/null +++ b/website/docs/rules/generated/cc-os-006.md @@ -0,0 +1,54 @@ +--- +id: cc-os-006 +title: "CC-OS-006: Invalid Output Style Frontmatter Syntax" +sidebar_label: "CC-OS-006" +description: "agnix rule CC-OS-006 checks for invalid output style frontmatter syntax in claude-output-styles files. Severity: HIGH. See examples and fix guidance." +keywords: ["CC-OS-006", "invalid output style frontmatter syntax", "claude-output-styles", "validation", "agnix", "linter"] +--- + +## Summary + +- **Rule ID**: `CC-OS-006` +- **Severity**: `HIGH` +- **Category**: `claude-output-styles` +- **Normative Level**: `MUST` +- **Auto-Fix**: `No` +- **Verified On**: `2026-04-22` + +## Applicability + +- **Tool**: `claude-code` +- **Version Range**: `unspecified` +- **Spec Revision**: `unspecified` + +## Evidence Sources + +- https://code.claude.com/docs/en/output-styles + +## Test Coverage Metadata + +- Unit tests: `true` +- Fixture tests: `false` +- E2E tests: `false` + +## Examples + +The following examples demonstrate what triggers this rule and how to fix it. + +### Invalid + +```text +--- +name: Concise +description: Short replies +``` + +### Valid + +```text +--- +name: Concise +description: Short replies +--- +Be brief. +``` diff --git a/website/docs/rules/generated/cc-sk-018.md b/website/docs/rules/generated/cc-sk-018.md index 8394b4b12..b1c35d73b 100644 --- a/website/docs/rules/generated/cc-sk-018.md +++ b/website/docs/rules/generated/cc-sk-018.md @@ -13,7 +13,7 @@ keywords: ["CC-SK-018", "invalid effort value", "claude skills", "validation", " - **Category**: `Claude Skills` - **Normative Level**: `MUST` - **Auto-Fix**: `Yes (unsafe)` -- **Verified On**: `2026-03-28` +- **Verified On**: `2026-04-22` ## Applicability diff --git a/website/docs/rules/generated/cdx-pl-005.md b/website/docs/rules/generated/cdx-pl-005.md index e44f6f3b9..e79a6c626 100644 --- a/website/docs/rules/generated/cdx-pl-005.md +++ b/website/docs/rules/generated/cdx-pl-005.md @@ -38,11 +38,11 @@ The following examples demonstrate what triggers this rule and how to fix it. ### Invalid ```json -{"components": [{"path": "src/index.js"}]} +{"name": "my-plugin", "skills": "src"} ``` ### Valid ```json -{"components": [{"path": "./src/index.js"}]} +{"name": "my-plugin", "skills": "./src"} ``` diff --git a/website/docs/rules/generated/cdx-pl-006.md b/website/docs/rules/generated/cdx-pl-006.md index 5ab887cf7..e13544dd1 100644 --- a/website/docs/rules/generated/cdx-pl-006.md +++ b/website/docs/rules/generated/cdx-pl-006.md @@ -38,11 +38,11 @@ The following examples demonstrate what triggers this rule and how to fix it. ### Invalid ```json -{"components": [{"path": "./../../../etc/passwd"}]} +{"name": "my-plugin", "mcpServers": "./../../../etc/passwd"} ``` ### Valid ```json -{"components": [{"path": "./src/index.js"}]} +{"name": "my-plugin", "mcpServers": "./servers"} ``` diff --git a/website/docs/rules/generated/cdx-pl-007.md b/website/docs/rules/generated/cdx-pl-007.md index 8331ec6a4..840f04b87 100644 --- a/website/docs/rules/generated/cdx-pl-007.md +++ b/website/docs/rules/generated/cdx-pl-007.md @@ -38,11 +38,11 @@ The following examples demonstrate what triggers this rule and how to fix it. ### Invalid ```json -{"components": [{"path": "./"}]} +{"name": "my-plugin", "apps": "./"} ``` ### Valid ```json -{"components": [{"path": "./src/index.js"}]} +{"name": "my-plugin", "apps": "./apps"} ``` diff --git a/website/docs/rules/generated/cdx-pl-008.md b/website/docs/rules/generated/cdx-pl-008.md index 0c714e786..3d6e24d7e 100644 --- a/website/docs/rules/generated/cdx-pl-008.md +++ b/website/docs/rules/generated/cdx-pl-008.md @@ -38,11 +38,11 @@ The following examples demonstrate what triggers this rule and how to fix it. ### Invalid ```json -{"default_prompts": ["a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u"]} +{"name": "my-plugin", "interface": {"defaultPrompt": ["a", "b", "c", "d"]}} ``` ### Valid ```json -{"default_prompts": ["Fix the bug", "Add tests"]} +{"name": "my-plugin", "interface": {"defaultPrompt": ["Fix the bug", "Add tests"]}} ``` diff --git a/website/docs/rules/generated/cdx-pl-009.md b/website/docs/rules/generated/cdx-pl-009.md index 661dcce92..0b80b8b5c 100644 --- a/website/docs/rules/generated/cdx-pl-009.md +++ b/website/docs/rules/generated/cdx-pl-009.md @@ -38,11 +38,11 @@ The following examples demonstrate what triggers this rule and how to fix it. ### Invalid ```json -{"default_prompts": ["<500+ character prompt string>"]} +{"name": "my-plugin", "interface": {"defaultPrompt": ["<500+ character prompt string>"]}} ``` ### Valid ```json -{"default_prompts": ["Fix the login bug in auth module"]} +{"name": "my-plugin", "interface": {"defaultPrompt": ["Fix the login bug"]}} ``` diff --git a/website/docs/rules/generated/cdx-pl-010.md b/website/docs/rules/generated/cdx-pl-010.md index 516d9f9c6..d9bbe55d1 100644 --- a/website/docs/rules/generated/cdx-pl-010.md +++ b/website/docs/rules/generated/cdx-pl-010.md @@ -38,11 +38,11 @@ The following examples demonstrate what triggers this rule and how to fix it. ### Invalid ```json -{"default_prompts": ["Fix the bug", "", "Add tests"]} +{"name": "my-plugin", "interface": {"defaultPrompt": ["Fix the bug", "", "Add tests"]}} ``` ### Valid ```json -{"default_prompts": ["Fix the bug", "Add tests"]} +{"name": "my-plugin", "interface": {"defaultPrompt": ["Fix the bug", "Add tests"]}} ``` diff --git a/website/docs/rules/generated/cdx-pl-011.md b/website/docs/rules/generated/cdx-pl-011.md index 9076c5b68..b35fe4df5 100644 --- a/website/docs/rules/generated/cdx-pl-011.md +++ b/website/docs/rules/generated/cdx-pl-011.md @@ -38,11 +38,11 @@ The following examples demonstrate what triggers this rule and how to fix it. ### Invalid ```json -{"interface": "not a url"} +{"name": "my-plugin", "interface": {"websiteUrl": "not a url"}} ``` ### Valid ```json -{"interface": "https://example.com/plugin-ui"} +{"name": "my-plugin", "interface": {"websiteUrl": "https://example.com"}} ``` diff --git a/website/docs/rules/generated/cdx-pl-012.md b/website/docs/rules/generated/cdx-pl-012.md index ff54e2eef..e3a96f7ec 100644 --- a/website/docs/rules/generated/cdx-pl-012.md +++ b/website/docs/rules/generated/cdx-pl-012.md @@ -38,11 +38,11 @@ The following examples demonstrate what triggers this rule and how to fix it. ### Invalid ```json -{"assets": ["icons/logo.png", "../outside/file.txt"]} +{"name": "my-plugin", "interface": {"logo": "assets/logo.png"}} ``` ### Valid ```json -{"assets": ["./icons/logo.png", "./styles/theme.css"]} +{"name": "my-plugin", "interface": {"logo": "./assets/logo.png"}} ``` diff --git a/website/docs/rules/generated/cdx-pl-013.md b/website/docs/rules/generated/cdx-pl-013.md index e0e1fa8eb..6b47ef2da 100644 --- a/website/docs/rules/generated/cdx-pl-013.md +++ b/website/docs/rules/generated/cdx-pl-013.md @@ -38,11 +38,11 @@ The following examples demonstrate what triggers this rule and how to fix it. ### Invalid ```json -{"name": "my-plugin", "hooks": {"on_load": "./init.js"}} +{"name": "my-plugin", "hooks": {"preStart": "echo hi"}} ``` ### Valid ```json -{"name": "my-plugin", "components": [{"path": "./src/index.js"}]} +{"name": "my-plugin", "skills": "./skills"} ``` diff --git a/website/docs/rules/index.md b/website/docs/rules/index.md index b805bf00f..21d105705 100644 --- a/website/docs/rules/index.md +++ b/website/docs/rules/index.md @@ -1,6 +1,6 @@ # Rules Reference -This section contains all `399` validation rules generated from `knowledge-base/rules.json`. +This section contains all `405` validation rules generated from `knowledge-base/rules.json`. `126` rules have automatic fixes. | Rule | Name | Severity | Category | Auto-Fix | @@ -89,6 +89,12 @@ This section contains all `399` validation rules generated from `knowledge-base/ | [CC-MEM-010](./generated/cc-mem-010.md) | README Duplication | MEDIUM | Claude Memory | No | | [CC-MEM-011](./generated/cc-mem-011.md) | Invalid Paths Glob in Rules | HIGH | Claude Memory | No | | [CC-MEM-012](./generated/cc-mem-012.md) | Rules File Unknown Frontmatter Key | MEDIUM | Claude Memory | Yes (unsafe) | +| [CC-OS-001](./generated/cc-os-001.md) | Output Style Missing Description | LOW | claude-output-styles | No | +| [CC-OS-002](./generated/cc-os-002.md) | Output Style Invalid keep-coding-instructions Type | HIGH | claude-output-styles | No | +| [CC-OS-003](./generated/cc-os-003.md) | Output Style Unknown Frontmatter Key | MEDIUM | claude-output-styles | No | +| [CC-OS-004](./generated/cc-os-004.md) | Output Style Empty Body | MEDIUM | claude-output-styles | No | +| [CC-OS-005](./generated/cc-os-005.md) | Output Style Name Exceeds Length | LOW | claude-output-styles | No | +| [CC-OS-006](./generated/cc-os-006.md) | Invalid Output Style Frontmatter Syntax | HIGH | claude-output-styles | No | | [CC-MEM-014](./generated/cc-mem-014.md) | CLAUDE.md Exceeds Line Limit | MEDIUM | Claude Memory | No | | [CC-PL-001](./generated/cc-pl-001.md) | Plugin Manifest Not in .claude-plugin/ | HIGH | Claude Plugins | No | | [CC-PL-002](./generated/cc-pl-002.md) | Components in .claude-plugin/ | HIGH | Claude Plugins | No | diff --git a/website/src/data/siteData.json b/website/src/data/siteData.json index 6c5b428e6..5158e0f6d 100644 --- a/website/src/data/siteData.json +++ b/website/src/data/siteData.json @@ -1,6 +1,6 @@ { - "totalRules": 399, - "categoryCount": 36, + "totalRules": 405, + "categoryCount": 37, "autofixCount": 126, "uniqueTools": [ "amp",