1use std::collections::{BTreeMap, HashSet};
3use std::path::Path;
4
5use lean_ctx::core::ocla::cache_types::{CacheKeyBuilder, ComposedContextKey};
6use rmcp::ErrorData;
7use rmcp::model::Tool;
8use serde_json::{Map, Value, json};
9
10use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_bool, get_str};
11use lean_ctx::tool_defs::tool_def;
12
13pub struct CtxComposeTool;
14
15const MIN_TASK_AWARE_COVERAGE: f32 = 0.20;
16const TASK_AWARE_TOP_K: usize = 2;
17const TASK_AWARE_DIVERSE_SECTIONS: usize = 1;
18const PROFILE_KEYWORD_WEIGHT: f32 = 0.45;
19const STOP_WORD_WEIGHT: f32 = 0.20;
20const QUERY_STOP_WORDS: &[&str] = &[
21 "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "how", "in", "is", "it", "of",
22 "on", "or", "that", "the", "this", "to", "use", "what", "with",
23];
24
25#[derive(Debug, Clone)]
26struct QueryKeyword {
27 term: String,
28 weight: f32,
29}
30
31#[derive(Debug)]
32struct ScoredSection<'a> {
33 index: usize,
34 text: &'a str,
35 score: f32,
36 matched_terms: HashSet<String>,
37}
38
39fn normalized_keywords(input: &str) -> Vec<String> {
40 let mut keywords = input
41 .split(|character: char| !character.is_alphanumeric())
42 .map(str::to_lowercase)
43 .filter(|keyword| keyword.chars().count() > 1)
44 .collect::<Vec<_>>();
45 keywords.sort_unstable();
46 keywords.dedup();
47 keywords
48}
49
50fn add_keywords(weights: &mut BTreeMap<String, f32>, input: &str, source_weight: f32) {
51 for keyword in normalized_keywords(input) {
52 let weight = if QUERY_STOP_WORDS.contains(&keyword.as_str()) {
53 source_weight * STOP_WORD_WEIGHT
54 } else {
55 source_weight
56 };
57 weights
58 .entry(keyword)
59 .and_modify(|existing| *existing = (*existing).max(weight))
60 .or_insert(weight);
61 }
62}
63
64fn task_aware_keywords(
65 task: &str,
66 profile: &lean_ctx::core::triage::profile::TaskProfileLocal,
67) -> Vec<QueryKeyword> {
68 let mut weights = BTreeMap::new();
69 add_keywords(&mut weights, task, 1.0);
72 add_keywords(&mut weights, &profile.task_class, PROFILE_KEYWORD_WEIGHT);
73 add_keywords(&mut weights, &profile.intent, PROFILE_KEYWORD_WEIGHT);
74 weights
75 .into_iter()
76 .map(|(term, weight)| QueryKeyword { term, weight })
77 .collect()
78}
79
80fn score_section(chunk: &str, keywords: &[QueryKeyword]) -> (f32, HashSet<String>) {
81 let terms = normalized_keywords(chunk)
82 .into_iter()
83 .collect::<HashSet<_>>();
84 let mut matched_terms = HashSet::new();
85 let mut matched_weight = 0.0;
86 let mut total_weight = 0.0;
87
88 for keyword in keywords {
89 total_weight += keyword.weight;
90 if terms.contains(&keyword.term) {
91 matched_weight += keyword.weight;
92 matched_terms.insert(keyword.term.clone());
93 }
94 }
95
96 let score = if total_weight > 0.0 {
97 matched_weight / total_weight
98 } else {
99 0.0
100 };
101 (score, matched_terms)
102}
103
104fn select_task_aware_sections<'a>(
105 mut sections: Vec<ScoredSection<'a>>,
106 keywords: &[QueryKeyword],
107 task_terms: &HashSet<String>,
108) -> Vec<ScoredSection<'a>> {
109 sections.retain(|section| section.score >= MIN_TASK_AWARE_COVERAGE);
110 sections.sort_by(|left, right| {
111 right
112 .score
113 .total_cmp(&left.score)
114 .then_with(|| left.index.cmp(&right.index))
115 });
116
117 let top_k = sections.len().min(TASK_AWARE_TOP_K);
118 let mut selected = sections.drain(..top_k).collect::<Vec<_>>();
119 let mut covered_terms = selected
120 .iter()
121 .flat_map(|section| section.matched_terms.iter().cloned())
122 .collect::<HashSet<_>>();
123
124 for _ in 0..TASK_AWARE_DIVERSE_SECTIONS {
125 let Some((position, _)) = sections
126 .iter()
127 .enumerate()
128 .filter_map(|(position, section)| {
129 let novel_weight = keywords
130 .iter()
131 .filter(|keyword| {
132 task_terms.contains(&keyword.term)
133 && section.matched_terms.contains(&keyword.term)
134 && !covered_terms.contains(&keyword.term)
135 })
136 .map(|keyword| keyword.weight)
137 .sum::<f32>();
138 (novel_weight > 0.0).then_some((position, novel_weight))
139 })
140 .max_by(
141 |(left_position, left_weight), (right_position, right_weight)| {
142 left_weight
143 .total_cmp(right_weight)
144 .then_with(|| {
145 sections[*left_position]
146 .score
147 .total_cmp(§ions[*right_position].score)
148 })
149 .then_with(|| {
150 sections[*right_position]
151 .index
152 .cmp(§ions[*left_position].index)
153 })
154 },
155 )
156 else {
157 break;
158 };
159
160 let section = sections.remove(position);
161 covered_terms.extend(section.matched_terms.iter().cloned());
162 selected.push(section);
163 }
164
165 selected
166}
167
168fn apply_task_aware_filter(
169 output: &str,
170 task: &str,
171 profile: &lean_ctx::core::triage::profile::TaskProfileLocal,
172 enabled: bool,
173) -> String {
174 if !enabled {
175 return output.to_owned();
176 }
177
178 let task_terms = normalized_keywords(task)
179 .into_iter()
180 .collect::<HashSet<_>>();
181 if task_terms.is_empty() {
182 return output.to_owned();
183 }
184 let keywords = task_aware_keywords(task, profile);
185 if keywords.is_empty() {
186 return output.to_owned();
187 }
188
189 let mut chunks = output.split("\n## ");
190 let prefix = chunks.next().unwrap_or_default();
191 let sections = chunks
192 .enumerate()
193 .map(|(index, chunk)| {
194 let (score, matched_terms) = score_section(chunk, &keywords);
195 ScoredSection {
196 index,
197 text: chunk,
198 score,
199 matched_terms,
200 }
201 })
202 .collect::<Vec<_>>();
203 let selected = select_task_aware_sections(sections, &keywords, &task_terms);
204 if selected.is_empty() {
205 return output.to_owned();
206 }
207
208 let mut filtered = prefix.to_owned();
211 for section in selected {
212 filtered.push_str("\n## ");
213 filtered.push_str(section.text);
214 }
215 filtered
216}
217
218fn current_task_profile(
219 ctx: &ToolContext,
220) -> Option<lean_ctx::core::triage::profile::TaskProfileLocal> {
221 let session = ctx.session.as_ref()?.try_read().ok()?;
222 lean_ctx::core::decision_loop_runtime::DecisionLoopRuntime::get_or_init()
223 .profile_for_session(&session.id)
224}
225
226const SOLUTION_HINT_ITEM_LIMIT: usize = 20;
227const UTILITY_DIRECTORIES: &[&str] = &["utils", "helpers", "common"];
228
229fn project_dependencies(project_root: &Path) -> Vec<String> {
231 let mut dependencies = Vec::new();
232 cargo_dependencies(&project_root.join("Cargo.toml"), &mut dependencies);
233 package_dependencies(&project_root.join("package.json"), &mut dependencies);
234 requirements_dependencies(&project_root.join("requirements.txt"), &mut dependencies);
235 go_dependencies(&project_root.join("go.mod"), &mut dependencies);
236 dependencies
237}
238
239fn read_manifest(manifest: &Path) -> std::io::Result<String> {
240 let root = manifest.parent().unwrap_or(manifest);
241 crate::tools::ctx_read::read_file_for_tool_rooted(
242 &manifest.to_string_lossy(),
243 &root.to_string_lossy(),
244 "ctx_compose",
245 )
246}
247
248fn cargo_dependencies(manifest: &Path, dependencies: &mut Vec<String>) {
249 let Ok(content) = read_manifest(manifest) else {
250 return;
251 };
252
253 let mut in_dependencies = false;
254 for line in content.lines() {
255 let trimmed = line.trim();
256 if let Some(section) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
257 in_dependencies = section == "dependencies"
258 || section == "workspace.dependencies"
259 || section.ends_with(".dependencies");
260 continue;
261 }
262 if !in_dependencies || trimmed.is_empty() || trimmed.starts_with('#') {
263 continue;
264 }
265 if let Some((name, _)) = trimmed.split_once('=') {
266 add_dependency(dependencies, name.trim().trim_matches(['\"', '\'']));
267 }
268 }
269}
270
271fn package_dependencies(manifest: &Path, dependencies: &mut Vec<String>) {
272 let Ok(content) = read_manifest(manifest) else {
273 return;
274 };
275
276 for section in ["dependencies", "devDependencies"] {
277 let Some(dependency_object) = json_object_section(&content, section) else {
278 continue;
279 };
280 for entry in dependency_object.split(',') {
281 let name = entry
282 .trim_start()
283 .strip_prefix('"')
284 .and_then(|entry| entry.split_once('"'))
285 .map(|(name, _)| name)
286 .unwrap_or_default();
287 add_dependency(dependencies, name);
288 }
289 }
290}
291
292fn json_object_section<'a>(content: &'a str, section: &str) -> Option<&'a str> {
293 let section_marker = format!("\"{section}\"");
294 let section_start = content.find(§ion_marker)? + section_marker.len();
295 let value = content[section_start..].split_once(':')?.1.trim_start();
296 if !value.starts_with('{') {
297 return None;
298 }
299 let object_start = content.len() - value.len();
300 let mut depth = 0usize;
301 let mut in_string = false;
302 let mut escaped = false;
303
304 for (offset, character) in content[object_start..].char_indices() {
305 if in_string {
306 if escaped {
307 escaped = false;
308 } else if character == '\\' {
309 escaped = true;
310 } else if character == '"' {
311 in_string = false;
312 }
313 continue;
314 }
315 match character {
316 '"' => in_string = true,
317 '{' => depth += 1,
318 '}' => {
319 depth -= 1;
320 if depth == 0 {
321 return Some(&content[object_start + 1..object_start + offset]);
322 }
323 }
324 _ => {}
325 }
326 }
327 None
328}
329
330fn requirements_dependencies(manifest: &Path, dependencies: &mut Vec<String>) {
331 let Ok(content) = read_manifest(manifest) else {
332 return;
333 };
334
335 for line in content.lines() {
336 let requirement = line.split('#').next().unwrap_or_default().trim();
337 if requirement.is_empty() || requirement.starts_with('-') {
338 continue;
339 }
340 let name = requirement
341 .split(|c: char| matches!(c, '=' | '<' | '>' | '!' | '~' | ';' | '[' | ' '))
342 .next()
343 .unwrap_or_default()
344 .trim();
345 add_dependency(dependencies, name);
346 }
347}
348
349fn go_dependencies(manifest: &Path, dependencies: &mut Vec<String>) {
350 let Ok(content) = read_manifest(manifest) else {
351 return;
352 };
353
354 let mut in_require_block = false;
355 for line in content.lines() {
356 let trimmed = line.split("//").next().unwrap_or_default().trim();
357 if trimmed.is_empty() {
358 continue;
359 }
360 if let Some(requirement) = trimmed.strip_prefix("require") {
361 let requirement = requirement.trim();
362 if requirement == "(" {
363 in_require_block = true;
364 } else if let Some(name) = requirement.split_whitespace().next() {
365 add_dependency(dependencies, name);
366 }
367 continue;
368 }
369 if in_require_block && trimmed == ")" {
370 in_require_block = false;
371 continue;
372 }
373 if in_require_block {
374 if let Some(name) = trimmed.split_whitespace().next() {
375 add_dependency(dependencies, name);
376 }
377 }
378 }
379}
380
381fn add_dependency(dependencies: &mut Vec<String>, dependency: &str) {
382 if !dependency.is_empty() && !dependencies.iter().any(|known| known == dependency) {
383 dependencies.push(dependency.to_string());
384 }
385}
386
387fn project_utility_files(project_root: &Path) -> Vec<String> {
389 let mut files = Vec::new();
390 let protected = lean_ctx::core::policy::runtime::is_active();
391 let mut inspected = 0usize;
392 let mut remaining_bytes = 8 * 1024 * 1024;
393 'directories: for directory in UTILITY_DIRECTORIES {
394 let utility_root = project_root.join(directory);
395 if !utility_root.is_dir() {
396 continue;
397 }
398 for entry in ignore::WalkBuilder::new(&utility_root)
399 .hidden(false)
400 .follow_links(false)
401 .build()
402 .flatten()
403 .take(256)
404 {
405 if !entry
406 .file_type()
407 .is_some_and(|file_type| file_type.is_file())
408 {
409 continue;
410 }
411 if let Ok(relative_path) = entry.path().strip_prefix(project_root) {
412 if lean_ctx::core::io_boundary::jail_and_check_path(
413 "ctx_compose",
414 entry.path(),
415 project_root,
416 )
417 .is_err()
418 {
419 continue;
420 }
421 if protected {
422 if inspected >= 32 || remaining_bytes == 0 {
423 break 'directories;
424 }
425 inspected += 1;
426 let Ok(_) = crate::tools::ctx_read::read_file_for_tool_rooted_budgeted(
427 &entry.path().to_string_lossy(),
428 &project_root.to_string_lossy(),
429 "ctx_compose",
430 &mut remaining_bytes,
431 ) else {
432 continue;
433 };
434 }
435 files.push(relative_path.to_string_lossy().replace('\\', "/"));
436 }
437 }
438 }
439 files.sort();
440 files.dedup();
441 files
442}
443
444fn utility_hint(utility_files: &[String]) -> Option<String> {
445 (!utility_files.is_empty()).then(|| {
446 let listed = utility_files
447 .iter()
448 .take(SOLUTION_HINT_ITEM_LIMIT)
449 .map(String::as_str)
450 .collect::<Vec<_>>();
451 let remaining = utility_files.len().saturating_sub(listed.len());
452 let suffix = (remaining > 0).then(|| format!(", … (+{remaining} more)"));
453 format!(
454 "Existing utilities: {}{}",
455 listed.join(", "),
456 suffix.unwrap_or_default()
457 )
458 })
459}
460
461fn project_stdlib_hint(project_root: &Path) -> &'static str {
464 if read_manifest(&project_root.join("Cargo.toml")).is_ok() {
465 "std::{fs, path, collections, io, process}"
466 } else if read_manifest(&project_root.join("package.json")).is_ok() {
467 "node:fs, node:path, node:util, node:child_process"
468 } else if read_manifest(&project_root.join("requirements.txt")).is_ok()
469 || read_manifest(&project_root.join("pyproject.toml")).is_ok()
470 {
471 "pathlib, collections, json, subprocess"
472 } else if read_manifest(&project_root.join("go.mod")).is_ok() {
473 "io, os, path/filepath, strings, net/http"
474 } else {
475 "filesystem, collections, and process APIs"
476 }
477}
478
479fn estimate_raw_input_tokens(compose_output: &str, project_root: &str) -> usize {
480 if lean_ctx::core::policy::runtime::is_active() {
483 return 0;
484 }
485 let mut seen = HashSet::new();
486 let mut raw_bytes: u64 = 0;
487 let root = Path::new(project_root);
488
489 for line in compose_output.lines() {
490 let trimmed = line.trim();
491 let candidate = if let Some(rest) = trimmed.strip_prefix("// ") {
492 rest.split(':').next().map(str::trim)
493 } else if trimmed.bytes().next().is_some_and(|b| b.is_ascii_digit()) {
494 trimmed
495 .split_once(". ")
496 .map(|x| x.1)
497 .and_then(|s| s.split(" (").next())
498 .map(str::trim)
499 } else if trimmed.contains(':') && !trimmed.starts_with('#') && !trimmed.starts_with("TASK")
500 {
501 let part = trimmed.split(':').next().unwrap_or("").trim();
502 if part.contains('.') && !part.contains(' ') {
503 Some(part)
504 } else {
505 None
506 }
507 } else {
508 None
509 };
510
511 if let Some(rel) = candidate {
512 if rel.is_empty() || rel.len() > 256 {
513 continue;
514 }
515 let full = root.join(rel);
516 if lean_ctx::core::io_boundary::jail_and_check_path("ctx_compose", &full, root).is_err()
517 {
518 continue;
519 }
520 if seen.insert(full.clone()) {
521 if let Ok(meta) = std::fs::metadata(&full) {
522 if meta.is_file() {
523 raw_bytes += meta.len();
524 }
525 }
526 }
527 }
528 }
529
530 (raw_bytes / 4) as usize
531}
532
533impl McpTool for CtxComposeTool {
534 fn name(&self) -> &'static str {
535 "ctx_compose"
536 }
537
538 fn tool_def(&self) -> Tool {
539 tool_def(
540 "ctx_compose",
541 "First-pass context for one task; returns ranked files and inline source — use instead of search→read chains.",
542 json!({
543 "type": "object",
544 "properties": {
545 "task": { "type": "string", "description": "Short English task/question or symbol names" },
546 "path": { "type": "string", "description": "Project root" },
547 "task_aware": { "type": "boolean", "default": true, "description": "Rank compose sections against the task (default: true)" }
548 },
549 "required": ["task"]
550 }),
551 )
552 }
553
554 fn handle(
555 &self,
556 args: &Map<String, Value>,
557 ctx: &ToolContext,
558 ) -> Result<ToolOutput, ErrorData> {
559 let mut pending_cache = None;
560 let result =
561 lean_ctx::core::policy::runtime::with_project_source_view(&ctx.project_root, || {
562 let task = ["task", "query", "question", "prompt", "goal"]
565 .iter()
566 .find_map(|key| get_str(args, key).filter(|v| !v.trim().is_empty()))
567 .ok_or_else(|| {
568 ErrorData::invalid_params(
569 "task is required — the question or goal to gather context for, \
570 e.g. ctx_compose(task=\"how is auth wired\")",
571 None,
572 )
573 })?;
574 let task_aware = get_bool(args, "task_aware").unwrap_or(true);
575 let path = if let Some(p) = ctx.resolved_path("path") {
576 p.to_string()
577 } else if let Some(err) = ctx.path_error("path") {
578 return Err(ErrorData::invalid_params(format!("path: {err}"), None));
579 } else {
580 ctx.project_root.clone()
581 };
582
583 if let Some(ref cache) = ctx.bm25_cache {
585 crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
586 }
587
588 let cache_enabled = lean_ctx::core::config::Config::load()
589 .cache
590 .compose_cache_enabled;
591 let cache_task =
592 lean_ctx::core::context_kernel::bridge::runtime::compose_cache_task(&task);
593 let cached = cache_enabled
594 .then(|| lean_ctx::core::ocla::compose_cache::global().check(&cache_task, &path))
595 .flatten();
596 let (text, _) = if let Some(text) = cached {
597 let sent = lean_ctx::core::tokens::count_tokens(&text);
598 (text, sent)
599 } else {
600 let source_revision = lean_ctx::core::ocla::compose_cache::source_revision(&path);
602 let compose_builder = ComposedContextKey {
603 task: cache_task.clone(),
604 path: path.clone(),
605 source_digests: source_revision.iter().cloned().collect(),
606 };
607 let ck = compose_builder.cache_key();
608 let cv = compose_builder.validator();
609 if let Some(entry) = source_revision.as_ref().and_then(|_| {
610 lean_ctx::core::ocla::cache_delivery::check(&ck, &cv, "ctx_compose")
611 }) {
612 let stub = lean_ctx::core::ocla::cache_delivery::stub(&entry, "compose");
613 let sent = lean_ctx::core::tokens::count_tokens(&stub);
614 (stub, sent)
615 } else {
616 let (text, sent) = tokio::task::block_in_place(|| {
617 crate::tools::ctx_compose::handle(&task, &path, ctx.crp_mode)
618 });
619 if cache_enabled && source_revision.is_some() && !text.starts_with("ERROR")
620 {
621 pending_cache = Some((
622 cache_task,
623 path.clone(),
624 text.clone(),
625 source_revision,
626 ck,
627 cv,
628 ));
629 }
630 (text, sent)
631 }
632 };
633
634 if text.starts_with("ERROR") {
635 return Err(ErrorData::invalid_params(text, None));
636 }
637
638 let text = current_task_profile(ctx).map_or_else(
639 || text.clone(),
640 |profile| apply_task_aware_filter(&text, &task, &profile, task_aware),
641 );
642 let text = {
643 let cfg = lean_ctx::core::config::Config::load();
644 if cfg.solution.enabled && cfg.solution.inject_in_compose {
645 let project_root = Path::new(&path);
646 let project_deps = project_dependencies(project_root);
647 let helper_files = project_utility_files(project_root);
648 let dependencies = if project_deps.is_empty() {
649 "no manifest dependencies detected".to_string()
650 } else {
651 project_deps.join(", ")
652 };
653 let mut hints = format!(
654 "--- SOLUTION HINTS ---\n\
655• Found {} existing helpers that may apply\n\
656• Project uses: {} (check before adding new)\n\
657• stdlib covers: {}",
658 helper_files.len(),
659 dependencies,
660 project_stdlib_hint(project_root),
661 );
662 if let Some(utility_hint) = utility_hint(&helper_files) {
663 hints.push('\n');
664 hints.push_str(&utility_hint);
665 }
666 if hints.is_empty() {
667 text
668 } else {
669 format!("{text}\n\n{hints}")
670 }
671 } else {
672 text
673 }
674 };
675 let sent = lean_ctx::core::tokens::count_tokens(&text);
676
677 let raw_tokens = estimate_raw_input_tokens(&text, &path);
678 let original = if raw_tokens > sent { raw_tokens } else { sent };
679 let saved = original.saturating_sub(sent);
680
681 Ok(ToolOutput {
682 text,
683 original_tokens: original,
684 saved_tokens: saved,
685 mode: Some("compose".to_string()),
686 path: Some(path),
687 changed: false,
688 shell_outcome: None,
689 content_blocks: None,
690 })
691 })
692 .unwrap_or_else(|reason| {
693 Err(ErrorData::internal_error(
696 format!(
697 "Context output withheld: source authority changed or could not be verified ({reason})"
698 ),
699 None,
700 ))
701 });
702 if result.is_ok() {
704 if let Some((task, path, text, Some(revision), key, validator)) = pending_cache {
705 let _ = lean_ctx::core::policy::runtime::with_project_source_view(
706 &ctx.project_root,
707 || {
708 if lean_ctx::core::ocla::compose_cache::global().record(
709 &task,
710 &path,
711 text.clone(),
712 &revision,
713 ) {
714 lean_ctx::core::ocla::cache_delivery::record(
715 key,
716 lean_ctx::core::ocla::cache_types::DeliveryKind::ComposedContext,
717 validator,
718 Some(path),
719 &text,
720 "ctx_compose",
721 );
722 }
723 },
724 );
725 }
726 }
727 result
728 }
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734 use lean_ctx::core::triage::profile::{TaskProfileLocal, TaskScopeLocal};
735
736 fn profile(task_class: &str, intent: &str, context_need_milli: u16) -> TaskProfileLocal {
737 TaskProfileLocal {
738 task_class: task_class.into(),
739 intent: intent.into(),
740 complexity: "low".into(),
741 scope: TaskScopeLocal::SingleFile,
742 context_need_milli,
743 reasoning_need_milli: 0,
744 risk_signal_milli: 0,
745 confidence_milli: 500,
746 }
747 }
748
749 #[test]
750 fn task_aware_filter_can_be_disabled() {
751 let output = "TASK: test\n\n## Unrelated\nwidget catalog\n";
752 assert_eq!(
753 apply_task_aware_filter(
754 output,
755 "target query",
756 &profile("bug_fix", "fix filtering", 700),
757 false
758 ),
759 output
760 );
761 }
762
763 use super::{project_dependencies, project_utility_files};
764
765 #[test]
766 fn detects_root_manifest_dependencies() {
767 let root = tempfile::tempdir().expect("tempdir");
768 std::fs::write(
769 root.path().join("Cargo.toml"),
770 "[dependencies]\ntokio = \"1\"\nserde = { version = \"1\" }\n",
771 )
772 .expect("Cargo.toml");
773 std::fs::write(
774 root.path().join("package.json"),
775 r#"{"dependencies":{"react":"1","zod":"1"},"devDependencies":{"vitest":"1"}}"#,
776 )
777 .expect("package.json");
778 std::fs::write(
779 root.path().join("requirements.txt"),
780 "requests>=2\nrich[markdown]==1\n# comment\n",
781 )
782 .expect("requirements.txt");
783 std::fs::write(
784 root.path().join("go.mod"),
785 "module example.com/project\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.0.0\n\tgolang.org/x/crypto v0.1.0\n)\n",
786 )
787 .expect("go.mod");
788
789 assert_eq!(
790 project_dependencies(root.path()),
791 vec![
792 "tokio".to_string(),
793 "serde".to_string(),
794 "react".to_string(),
795 "zod".to_string(),
796 "vitest".to_string(),
797 "requests".to_string(),
798 "rich".to_string(),
799 "github.com/gin-gonic/gin".to_string(),
800 "golang.org/x/crypto".to_string(),
801 ]
802 );
803 }
804
805 #[test]
806 fn task_aware_filter_uses_the_call_task_over_session_profile() {
807 let output = "TASK: test\n\n## Query A\nrenew oauth token\n\n## Query B\noauth renewal flow\n\n## Session profile\nmaintenance routine\n";
808 let filtered = apply_task_aware_filter(
809 output,
810 "renew oauth token",
811 &profile("maintenance", "routine", 700),
812 true,
813 );
814
815 assert!(filtered.contains("## Query A"));
816 assert!(filtered.contains("## Query B"));
817 assert!(!filtered.contains("## Session profile"));
818 }
819
820 #[test]
821 fn task_aware_filter_downweights_stop_words() {
822 let output =
823 "TASK: test\n\n## Generic\nhow to do this\n\n## Parser\nupdate parser properly\n";
824 let filtered = apply_task_aware_filter(
825 output,
826 "how to update the parser properly",
827 &profile("maintenance", "routine", 700),
828 true,
829 );
830
831 assert!(filtered.contains("## Parser"));
832 assert!(!filtered.contains("## Generic"));
833 }
834
835 #[test]
836 fn task_aware_filter_uses_top_k_then_diverse_coverage() {
837 let output = "TASK: test\n\n## A\ncache storage retries\n\n## B\ncache storage retries duplicate\n\n## C\nmetrics\n\n## D\ncache\n";
838 let filtered = apply_task_aware_filter(
839 output,
840 "cache storage retries metrics",
841 &profile("cache", "storage retries metrics", 700),
842 true,
843 );
844
845 assert!(filtered.contains("## A"));
846 assert!(filtered.contains("## B"));
847 assert!(filtered.contains("## C"));
848 assert!(!filtered.contains("## D"));
849 }
850
851 #[test]
852 fn task_aware_filter_leaves_triage_to_the_global_pipeline() {
853 let output = format!(
854 "TASK: test\n\n## Relevant\nfix context gate\n{}// TODO: keep this\n",
855 "// boilerplate\n".repeat(40)
856 );
857 let filtered = apply_task_aware_filter(
858 &output,
859 "fix context gate",
860 &profile("bug_fix", "fix context gate filtering", 450),
861 true,
862 );
863 assert!(filtered.contains("## Relevant"));
864 assert!(filtered.contains("// TODO: keep this"));
865 assert!(filtered.contains("// boilerplate"));
866 assert!(!filtered.contains("filtered by triage"));
867 }
868
869 #[test]
870 fn finds_files_in_root_utility_directories() {
871 let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
872 let root = tempfile::tempdir().expect("tempdir");
873 std::fs::create_dir_all(root.path().join("utils/nested")).expect("utils directory");
874 std::fs::create_dir_all(root.path().join("helpers")).expect("helpers directory");
875 std::fs::create_dir_all(root.path().join("common")).expect("common directory");
876 for file in [
877 "utils/nested/time.rs",
878 "helpers/format.ts",
879 "common/model.py",
880 ] {
881 std::fs::write(root.path().join(file), "").expect("utility file");
882 }
883
884 assert_eq!(
885 project_utility_files(root.path()),
886 vec![
887 "common/model.py".to_string(),
888 "helpers/format.ts".to_string(),
889 "utils/nested/time.rs".to_string(),
890 ]
891 );
892 }
893
894 #[test]
895 fn community_utility_names_obey_secret_path_role_admission() {
896 let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
897 let root = tempfile::tempdir().unwrap();
898 std::fs::create_dir(root.path().join("helpers")).unwrap();
899 std::fs::write(root.path().join("helpers/id_rsa"), "private key fixture").unwrap();
900 std::fs::write(root.path().join("helpers/allowed.rs"), "fn allowed() {}\n").unwrap();
901 let mut role = lean_ctx::core::roles::load_role("coder").unwrap();
902 role.io.allow_secret_paths = false;
903 lean_ctx::core::roles::with_test_active_role(role, || {
904 assert_eq!(
905 project_utility_files(root.path()),
906 vec!["helpers/allowed.rs"]
907 );
908 });
909 }
910}