diff --git a/crates/tui/src/context_report.rs b/crates/tui/src/context_report.rs index ca408d8a5a..5b34d7a0fa 100644 --- a/crates/tui/src/context_report.rs +++ b/crates/tui/src/context_report.rs @@ -334,9 +334,14 @@ fn base_source_entries(model: &str, workspace: &Path, skills_dir: Option<&Path>) .source_path .as_ref() .map_or_else(|| "project".to_string(), |p| p.display().to_string()); - let block = format!( + let mut block = format!( "\n{content}\n" ); + // Include rules in the report when present + if let Some(rules) = &project_context.rules_block { + block.push('\n'); + block.push_str(rules); + } builder.push(SourceEntry::text( SourceKind::ProjectContext, "Project instructions", @@ -349,6 +354,17 @@ fn base_source_entries(model: &str, workspace: &Path, skills_dir: Option<&Path>) CountingConfidence::High, Some(5), )); + } else if let Some(rules) = &project_context.rules_block { + // Rules exist without main instructions + builder.push(SourceEntry::text( + SourceKind::ProjectContext, + "Project rules", + None::, + ActivationReason::FilePresent, + rules, + CountingConfidence::High, + Some(5), + )); } if project_context.constitution_block.is_none() && project_context.instructions.is_none() { diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index 912807d344..4eaf005fef 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -39,6 +39,13 @@ const PROJECT_CONTEXT_FILES: &[&str] = &[ ".deepseek/instructions.md", ]; +/// Rules directories auto-discovered at workspace level, in priority order. +/// `.codewhale/rules/` is CodeWhale-native; `.claude/rules/` is Claude compatibility. +/// All `.md` files in these directories are loaded as project rules in filename order. +/// Security model: same trust class as AGENTS.md — workspace-contained content only, +/// no absolute-path escape. Does not require #417 project-config relaxation. +const RULES_DIRS: &[&str] = &[".codewhale/rules", ".claude/rules"]; + /// File name of the deprecated CodeWhale-native instructions file. const DEPRECATED_WHALE_FILENAME: &str = "WHALE.md"; @@ -72,6 +79,17 @@ const GLOBAL_INSTRUCTIONS_LEGACY_PATH: &[&str] = &[".deepseek", "instructions.md /// Maximum size for project context files (to prevent loading huge files) const MAX_CONTEXT_SIZE: usize = 100 * 1024; // 100KB + +/// Maximum number of rule files loaded per rules directory. +/// Prevents a project from silently injecting hundreds of rule files. +const MAX_RULES_FILES: usize = 50; + +/// Maximum total bytes across the assembled rules_block. +/// 50 files × 100 KB per file could reach ~5 MB; this caps the +/// cumulative injected content so a large rules directory can't +/// dominate the context window. Exceeded bytes are truncated with +/// an explicit marker. +const MAX_RULES_BLOCK_BYTES: usize = 500 * 1024; // 500 KB const PACK_README_MAX_CHARS: usize = 4_000; const PACK_MAX_ENTRIES: usize = 220; const PACK_MAX_SOURCE_FILES: usize = 60; @@ -133,6 +151,10 @@ enum ProjectContextError { pub struct ProjectContext { /// The loaded instructions content pub instructions: Option, + /// Auto-discovered rules from `.codewhale/rules/` / `.claude/rules/`. + /// Kept separate from `instructions` so rules alone don't block + /// parent-directory AGENTS.md discovery via `has_instructions()`. + pub rules_block: Option, /// Path to the loaded file (for display) pub source_path: Option, /// Any warnings during loading @@ -155,6 +177,7 @@ impl ProjectContext { pub fn empty(project_root: PathBuf) -> Self { Self { instructions: None, + rules_block: None, source_path: None, warnings: Vec::new(), constitution_block: None, @@ -181,18 +204,36 @@ impl ProjectContext { .as_ref() .map_or_else(|| "project".to_string(), |p| p.display().to_string()); - format!( + let mut block = format!( "\n{content}\n" - ) + ); + // Append rules after instructions, inside the same logical block. + // Rules are kept separate from `instructions` so they don't block + // parent-directory AGENTS.md discovery via `has_instructions()`. + if let Some(rules) = &self.rules_block { + block.push('\n'); + block.push_str(rules); + } + block }); match (self.constitution_block.as_ref(), instructions_block) { (Some(constitution), Some(instructions)) => { Some(format!("{constitution}\n\n{instructions}")) } - (Some(constitution), None) => Some(constitution.clone()), + (Some(constitution), None) => { + // Constitution present but no main instructions — still emit rules if any + if let Some(rules) = &self.rules_block { + Some(format!("{constitution}\n\n{rules}")) + } else { + Some(constitution.clone()) + } + } (None, Some(instructions)) => Some(instructions), - (None, None) => None, + (None, None) => { + // No main instructions, but rules may exist on their own + self.rules_block.clone() + } } } } @@ -841,6 +882,44 @@ pub fn load_project_context(workspace: &Path) -> ProjectContext { ctx.warnings .extend(ignored_project_whale_warnings(workspace)); + // Load rules from auto-discovered directories (.codewhale/rules/, .claude/rules/) + // Each rule file is wrapped in a block and appended after + // the main instructions content. Security model: same as AGENTS.md — + // workspace-contained content only, no absolute-path escape. + let mut rules_content = String::new(); + for rules_dir in RULES_DIRS { + let rules = load_rules_from_dir(workspace, rules_dir); + for (path, content) in rules { + if !rules_content.is_empty() { + rules_content.push('\n'); + } + rules_content.push_str(&format!( + "\n{}\n", + path.display(), + content.trim() + )); + } + } + + if !rules_content.is_empty() { + // Cap total rules bytes so a large rules dir can't dominate the context window + if rules_content.len() > MAX_RULES_BLOCK_BYTES { + let mut end = MAX_RULES_BLOCK_BYTES; + while !rules_content.is_char_boundary(end) { + end -= 1; + } + rules_content.truncate(end); + rules_content.push_str("\n\n[…rules block truncated at 500 KB…]"); + tracing::warn!( + target: "project_context", + total_bytes = rules_content.len(), + cap = MAX_RULES_BLOCK_BYTES, + "Truncating rules block to total byte budget" + ); + } + ctx.rules_block = Some(rules_content); + } + // Check for trust file ctx.is_trusted = check_trust_status(workspace); @@ -995,6 +1074,27 @@ pub(crate) fn project_context_cache_candidate_paths( paths.push(workspace.join(".deepseek").join("trust.json")); paths.extend(crate::config::workspace_trust_config_candidate_paths()); + // Include auto-discovered rules directory files so cache invalidates + // when rules change (not just when AGENTS.md changes). + for rules_dir in RULES_DIRS { + let dir_path = workspace.join(rules_dir); + // Skip symlinked rules directories (same guard as load_rules_from_dir) + if fs::symlink_metadata(&dir_path) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + continue; + } + if let Ok(entries) = std::fs::read_dir(&dir_path) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "md") { + paths.push(path); + } + } + } + } + paths } @@ -1222,6 +1322,85 @@ fn context_candidate_exists(path: &Path) -> bool { }) } +/// Scan a rules directory for `.md` files and load them in filename order. +/// Missing or unreadable directories return an empty vec (no error). +/// Each file is verified through `load_context_file` (size check, symlink safety). +fn load_rules_from_dir(workspace: &Path, rules_dir_name: &str) -> Vec<(PathBuf, String)> { + let rules_dir = workspace.join(rules_dir_name); + let mut entries: Vec<(PathBuf, String)> = Vec::new(); + + // Refuse a symlinked rules directory: the real .md files behind it + // would pass per-file is_symlink checks and be read from outside the + // workspace subtree — same escape class as #417. + if fs::symlink_metadata(&rules_dir) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + tracing::warn!( + target: "project_context", + dir = %rules_dir.display(), + "Refusing symlinked rules directory" + ); + return entries; + } + + let dir_iter = match fs::read_dir(&rules_dir) { + Ok(iter) => iter, + Err(_) => return entries, + }; + + let mut file_paths: Vec = Vec::new(); + for entry in dir_iter.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "md") && context_candidate_exists(&path) { + file_paths.push(path); + } + } + + // Sort by filename for deterministic order + file_paths.sort_by(|a, b| { + a.file_name() + .unwrap_or_default() + .cmp(b.file_name().unwrap_or_default()) + }); + + // Enforce per-directory cap + let total = file_paths.len(); + if total > MAX_RULES_FILES { + tracing::warn!( + target: "project_context", + dir = %rules_dir.display(), + total, + cap = MAX_RULES_FILES, + "Truncating rules directory to cap" + ); + file_paths.truncate(MAX_RULES_FILES); + } + + for path in file_paths { + match load_context_file(&path) { + Ok(content) => { + tracing::info!( + "Loaded project rule from {} ({} bytes)", + path.display(), + content.len() + ); + entries.push((path, content)); + } + Err(error) => { + tracing::warn!( + target: "project_context", + ?error, + ?path, + "Skipping unreadable rules file" + ); + } + } + } + + entries +} + #[cfg(unix)] fn open_context_file(path: &Path) -> Result { use std::os::unix::fs::OpenOptionsExt; @@ -2444,4 +2623,267 @@ mod tests { .contains("Project Context (Auto-generated, ephemeral)") ); } + + // ── Rules directory auto-discovery tests ── + + #[test] + fn rules_from_codewhale_dir_are_loaded_as_project_context() { + let tmp = tempdir().expect("tempdir"); + let rules_dir = tmp.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + fs::write( + rules_dir.join("security.md"), + "# Security\nNo hardcoded secrets.", + ) + .expect("write"); + + let ctx = load_project_context(tmp.path()); + + let rules = ctx.rules_block.as_ref().expect("rules_block should be set"); + assert!( + rules.contains("Security"), + "expected rules content, got: {rules}" + ); + assert!( + rules.contains(" wrapper, got: {rules}" + ); + } + + #[test] + fn rules_are_loaded_in_filename_order() { + let tmp = tempdir().expect("tempdir"); + let rules_dir = tmp.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + fs::write(rules_dir.join("zzz.md"), "last").expect("write"); + fs::write(rules_dir.join("aaa.md"), "first").expect("write"); + fs::write(rules_dir.join("mmm.md"), "middle").expect("write"); + + let ctx = load_project_context(tmp.path()); + let rules = ctx.rules_block.as_ref().unwrap(); + + let pos_aaa = rules.find("first").unwrap(); + let pos_mmm = rules.find("middle").unwrap(); + let pos_zzz = rules.find("last").unwrap(); + assert!(pos_aaa < pos_mmm, "aaa should come before mmm"); + assert!(pos_mmm < pos_zzz, "mmm should come before zzz"); + } + + #[test] + fn rules_from_claude_dir_are_compat_loaded() { + let tmp = tempdir().expect("tempdir"); + let rules_dir = tmp.path().join(".claude/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + fs::write(rules_dir.join("style.md"), "Use tabs").expect("write"); + + let ctx = load_project_context(tmp.path()); + + let rules = ctx.rules_block.as_ref().expect("rules should be loaded"); + assert!( + rules.contains("Use tabs"), + "expected .claude/rules/ compat loading" + ); + } + + #[test] + fn rules_directory_missing_does_not_crash() { + let tmp = tempdir().expect("tempdir"); + // No .codewhale/rules/ or .claude/rules/ directories exist + let ctx = load_project_context(tmp.path()); + // Rules block should be None when no rules directories exist + assert!( + ctx.rules_block.is_none(), + "rules_block should be None when no rules exist" + ); + } + + #[test] + fn rules_coexist_with_agents_md() { + let tmp = tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "Main project instructions").expect("write"); + let rules_dir = tmp.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + fs::write(rules_dir.join("extra.md"), "Extra rule").expect("write"); + + let ctx = load_project_context(tmp.path()); + let instructions = ctx.instructions.as_ref().unwrap(); + let rules = ctx.rules_block.as_ref().unwrap(); + + assert!( + instructions.contains("Main project instructions"), + "AGENTS.md content missing" + ); + assert!(rules.contains("Extra rule"), "rules content missing"); + // AGENTS.md should come first in system block + let block = ctx.as_system_block().unwrap(); + let pos_agents = block.find("Main project instructions").unwrap(); + let pos_rule = block.find("Extra rule").unwrap(); + assert!(pos_agents < pos_rule, "AGENTS.md should precede rules"); + } + + #[test] + fn non_md_files_in_rules_dir_are_ignored() { + let tmp = tempdir().expect("tempdir"); + let rules_dir = tmp.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + fs::write(rules_dir.join("notes.txt"), "should be ignored").expect("write"); + fs::write(rules_dir.join("valid.md"), "loaded").expect("write"); + + let ctx = load_project_context(tmp.path()); + let rules = ctx.rules_block.as_ref().unwrap(); + + assert!(rules.contains("loaded"), "valid .md should be loaded"); + assert!( + !rules.contains("should be ignored"), + ".txt should be ignored" + ); + } + + #[test] + fn rules_cap_truncates_excess_files() { + let tmp = tempdir().expect("tempdir"); + let rules_dir = tmp.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + + // Create more files than the cap + for i in 0..60 { + fs::write( + rules_dir.join(format!("rule_{i:04}.md")), + format!("content {i}"), + ) + .expect("write"); + } + + let ctx = load_project_context(tmp.path()); + let rules = ctx.rules_block.as_ref().unwrap(); + + // The last file (by sorted name) should NOT be present + assert!( + !rules.contains("content 59"), + "rule_0059 should be above cap" + ); + // The first file should be present + assert!( + rules.contains("content 0"), + "rule_0000 should be within cap" + ); + // Count blocks + let count = rules.matches(" {}", + rules.len(), + MAX_RULES_BLOCK_BYTES + ); + assert!( + rules.contains("truncated at 500 KB"), + "truncation marker missing" + ); + } } diff --git a/crates/tui/src/project_context_cache.rs b/crates/tui/src/project_context_cache.rs index aefb3adf0b..fd1ed50639 100644 --- a/crates/tui/src/project_context_cache.rs +++ b/crates/tui/src/project_context_cache.rs @@ -217,4 +217,42 @@ mod tests { assert_ne!(before, after); } + + #[test] + fn signature_changes_when_rules_file_changes() { + let workspace = tempdir().expect("workspace"); + let home = tempdir().expect("home"); + let rules_dir = workspace.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + fs::write(rules_dir.join("rule.md"), "alpha").expect("write alpha"); + + let before = compute_cache_key(workspace.path(), Some(home.path())); + + fs::write(rules_dir.join("rule.md"), "bravo").expect("write bravo"); + let after = compute_cache_key(workspace.path(), Some(home.path())); + + assert_ne!( + before, after, + "cache key must change when rules file changes" + ); + } + + #[test] + fn signature_changes_when_rules_file_is_added_or_removed() { + let workspace = tempdir().expect("workspace"); + let home = tempdir().expect("home"); + let rules_dir = workspace.path().join(".codewhale/rules"); + fs::create_dir_all(&rules_dir).expect("mkdir rules"); + + // No rules yet + let before = compute_cache_key(workspace.path(), Some(home.path())); + + fs::write(rules_dir.join("new.md"), "content").expect("write new.md"); + let after = compute_cache_key(workspace.path(), Some(home.path())); + + assert_ne!( + before, after, + "cache key must change when rules file is added" + ); + } }