1use std::sync::mpsc;
16use std::time::Duration;
17
18use crate::tools::CrpMode;
19use lean_ctx::core::tokens::count_tokens;
20
21const DEFAULT_SEMANTIC_BUDGET_MS: u64 = 4000;
28const ONE_SHOT_SEMANTIC_BUDGET_MS: u64 = 120_000;
32static RANKING_WORKERS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
35
36fn semantic_budget() -> Duration {
37 let ms = std::env::var("LEAN_CTX_COMPOSE_BUDGET_MS")
38 .ok()
39 .and_then(|v| v.parse::<u64>().ok())
40 .filter(|&v| v > 0)
41 .unwrap_or(if lean_ctx::core::conversation::is_one_shot_process() {
42 ONE_SHOT_SEMANTIC_BUDGET_MS
43 } else {
44 DEFAULT_SEMANTIC_BUDGET_MS
45 });
46 Duration::from_millis(ms)
47}
48
49const DEFAULT_SYMBOL_BUDGET_TOKENS: usize = 600;
53
54fn symbol_budget_tokens() -> usize {
55 std::env::var("LEAN_CTX_COMPOSE_SYMBOL_TOKENS")
56 .ok()
57 .and_then(|v| v.parse::<usize>().ok())
58 .filter(|&v| v > 0)
59 .unwrap_or(DEFAULT_SYMBOL_BUDGET_TOKENS)
60}
61
62pub(crate) fn kernel_supplement_budget(project_root: &str) -> usize {
63 let config = lean_ctx::core::context_kernel::activation::load_config(project_root);
64 (symbol_budget_tokens() / 5)
65 .min(lean_ctx::core::context_kernel::activation::supplement_budget(&config))
66}
67
68const STOPWORDS: &[&str] = &[
70 "the",
71 "and",
72 "for",
73 "with",
74 "that",
75 "this",
76 "from",
77 "into",
78 "how",
79 "where",
80 "what",
81 "does",
82 "are",
83 "was",
84 "use",
85 "used",
86 "uses",
87 "add",
88 "all",
89 "any",
90 "can",
91 "get",
92 "set",
93 "via",
94 "out",
95 "its",
96 "his",
97 "her",
98 "you",
99 "your",
100 "our",
101 "find",
102 "show",
103 "list",
104 "make",
105 "when",
106 "then",
107 "has",
108 "have",
109 "had",
110 "not",
111 "but",
112 "see",
113 "function",
114 "method",
115 "class",
116 "code",
117 "file",
118 "files",
119 "implement",
120 "implementation",
121];
122
123fn extract_keywords(task: &str, max: usize) -> Vec<String> {
126 let mut seen = std::collections::HashSet::new();
127 let mut out = Vec::new();
128 for raw in task.split(|c: char| !(c.is_alphanumeric() || c == '_')) {
129 if raw.len() < 3 {
130 continue;
131 }
132 if STOPWORDS.contains(&raw.to_ascii_lowercase().as_str()) {
133 continue;
134 }
135 if seen.insert(raw.to_string()) {
136 out.push(raw.to_string());
137 if out.len() >= max {
138 break;
139 }
140 }
141 }
142 out
143}
144
145fn order_by_specificity(keywords: &[String], project_root: &str) -> Vec<String> {
154 let Some(index) = resident_index(project_root) else {
155 return keywords.to_vec();
156 };
157 rank_by_doc_freq(keywords, &index.doc_freqs)
158}
159
160fn rank_by_doc_freq(
172 keywords: &[String],
173 doc_freqs: &std::collections::HashMap<String, usize>,
174) -> Vec<String> {
175 let df = |kw: &String| match doc_freqs.get(&kw.to_ascii_lowercase()) {
176 Some(&n) if n > 0 => n,
177 _ => usize::MAX,
178 };
179 let rank_key = |kw: &String| (u8::from(!is_code_identifier(kw)), df(kw));
181 let mut ranked = keywords.to_vec();
182 ranked.sort_by_key(rank_key);
183 ranked
184}
185
186fn is_code_identifier(kw: &str) -> bool {
191 if kw.contains('_') {
192 return true;
193 }
194 let has_lower = kw.chars().any(|c| c.is_ascii_lowercase());
195 let internal_upper = kw.chars().skip(1).any(|c| c.is_ascii_uppercase());
196 has_lower && internal_upper
197}
198
199fn resident_index(
202 project_root: &str,
203) -> Option<std::sync::Arc<lean_ctx::core::bm25_index::BM25Index>> {
204 let cache = crate::tools::ctx_semantic_search::get_thread_cache()?;
205 lean_ctx::core::bm25_cache::get_or_background(&cache, std::path::Path::new(project_root))
206}
207
208fn ranked_files_budgeted(task: &str, project_root: &str, crp_mode: CrpMode) -> String {
212 ranked_files_with_slots(task, project_root, crp_mode, &RANKING_WORKERS)
213}
214
215fn ranked_files_with_slots(
216 task: &str,
217 project_root: &str,
218 crp_mode: CrpMode,
219 slots: &'static tokio::sync::Semaphore,
220) -> String {
221 let role = lean_ctx::core::roles::active_role();
224 if role
225 .tools
226 .denied
227 .iter()
228 .any(|denied| denied == "ctx_search")
229 {
230 return "ERR: source search is not authorized".into();
231 }
232 let Ok(slot) = slots.try_acquire() else {
233 return "(local BM25 ranking busy: retry after active requests finish. Other sections are independently admitted.)".into();
234 };
235 let (tx, rx) = mpsc::channel::<String>();
236 let task_owned = task.to_string();
237 let root_owned = project_root.to_string();
238 let resident_cache = crate::tools::ctx_semantic_search::get_thread_cache();
243
244 lean_ctx::core::task_spine::TaskSpine::spawn_thread(move || {
245 if let Some(cache) = resident_cache {
246 crate::tools::ctx_semantic_search::set_thread_cache(cache);
247 }
248 let _slot = slot;
249 let ranked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
250 crate::tools::ctx_semantic_search::handle_for_tool(
251 "ctx_compose",
252 &task_owned,
253 &root_owned,
254 8,
255 crp_mode,
256 None,
257 None,
258 Some("bm25"),
259 Some(false),
260 Some(false),
261 )
262 }))
263 .unwrap_or_else(|_| {
264 tracing::warn!("[ctx_compose: semantic ranking panicked; omitting section]");
265 String::new()
266 });
267 let _ = tx.send(ranked);
270 });
271
272 match rx.recv_timeout(semantic_budget()) {
273 Ok(ranked) => ranked.trim().to_string(),
274 Err(_) => deferred_ranking_note().to_string(),
275 }
276}
277
278fn deferred_ranking_note() -> &'static str {
281 "(local BM25 ranking deferred: source admission did not finish within this call's budget; \
282 late ranking output is discarded. Other sections are independently admitted. \
283 LEAN_CTX_COMPOSE_BUDGET_MS raises the budget.)"
284}
285
286const PROSE_GLOBS: &str = "*.{md,mdx,markdown,rst,txt,adoc}";
289
290fn exact_matches_section(
295 primary: &str,
296 project_root: &str,
297 crp_mode: CrpMode,
298 allow_secret: bool,
299) -> String {
300 let search = |pattern: &str, max: usize, exclude: Option<&str>| {
301 crate::tools::ctx_search::handle_filtered(
302 pattern,
303 project_root,
304 None,
305 max,
306 crp_mode,
307 true,
308 allow_secret,
309 false,
310 exclude,
311 None,
312 )
313 .text
314 };
315 let found = |text: &str| {
316 let t = text.trim_start();
317 !(t.is_empty() || t.starts_with("0 matches") || t.starts_with("ERR"))
318 };
319 let mut out = String::new();
320 let definition = format!(
321 r"\b(?:fn|struct|enum|trait|type|const|static|mod|class|interface|def|func|function)\s+{}\b",
322 regex::escape(primary)
323 );
324 let defs = search(&definition, 5, None);
325 if found(&defs) {
326 out.push_str("Definitions:\n");
327 out.push_str(defs.trim());
328 out.push('\n');
329 }
330 let code = search(primary, 10, Some(PROSE_GLOBS));
331 if found(&code) {
332 out.push_str("Uses in code:\n");
333 out.push_str(code.trim());
334 out.push('\n');
335 } else {
336 out.push_str(search(primary, 10, None).trim());
337 out.push('\n');
338 }
339 out
340}
341
342const SMALL_FILE_MAX_LINES: usize = 150;
350const SMALL_FILE_MAX_TOKENS: usize = 800;
351const SMALL_FILES_MAX: usize = 4;
352const DEFAULT_SMALL_FILES_BUDGET_TOKENS: usize = 2_000;
355
356fn small_files_budget_tokens() -> usize {
357 std::env::var("LEAN_CTX_COMPOSE_INLINE_TOKENS")
358 .ok()
359 .and_then(|v| v.parse::<usize>().ok())
360 .unwrap_or(DEFAULT_SMALL_FILES_BUDGET_TOKENS)
361}
362
363fn named_files(composed: &str) -> Vec<String> {
367 let mut seen = std::collections::HashSet::new();
368 let mut files = Vec::new();
369 for line in composed.lines() {
370 let candidate = if let Some((path, _)) = line.split_once(" :: ") {
371 path.trim()
372 } else if let Some(rest) = line.strip_prefix("File: ") {
373 rest.split(" (").next().unwrap_or("").trim()
374 } else {
375 let Some((path, rest)) = line.split_once(':') else {
376 continue;
377 };
378 if !rest.starts_with(|c: char| c.is_ascii_digit()) {
379 continue;
380 }
381 path.trim()
382 };
383 if candidate.is_empty()
384 || candidate.contains(char::is_whitespace)
385 || !candidate.contains('.')
386 || candidate.starts_with('[')
387 {
388 continue;
389 }
390 if seen.insert(candidate.to_string()) {
391 files.push(candidate.to_string());
392 }
393 }
394 files
395}
396
397fn small_files_section(composed: &str, project_root: &str, allow_secret: bool) -> String {
403 let budget = small_files_budget_tokens();
404 if budget == 0 {
405 return String::new();
406 }
407 let mut used = 0usize;
408 let mut inlined = 0usize;
409 let mut section = String::new();
410 let mut remaining_bytes = lean_ctx::core::limits::max_read_bytes();
411 for rel in named_files(composed) {
412 if inlined >= SMALL_FILES_MAX {
413 break;
414 }
415 let path = std::path::Path::new(project_root).join(&rel);
416 if !allow_secret && lean_ctx::core::io_boundary::is_secret_like(&path).is_some() {
417 continue;
418 }
419 let Ok(meta) = std::fs::metadata(&path) else {
420 continue;
421 };
422 if !meta.is_file() || meta.len() > 16 * 1024 {
424 continue;
425 }
426 let Ok(content) = crate::tools::ctx_read::read_file_for_tool_rooted_budgeted(
427 &path.to_string_lossy(),
428 project_root,
429 "ctx_read",
430 &mut remaining_bytes,
431 ) else {
432 continue;
433 };
434 let lines = content.lines().count();
435 let tokens = count_tokens(&content);
436 if lines == 0
437 || lines > SMALL_FILE_MAX_LINES
438 || tokens > SMALL_FILE_MAX_TOKENS
439 || used + tokens > budget
440 {
441 continue;
442 }
443 let longest_run = content.split(|c| c != '`').map(str::len).max().unwrap_or(0);
444 let fence = "`".repeat(longest_run.max(2) + 1);
445 if section.is_empty() {
446 section.push_str("\n## Small files (full, verbatim)\n");
447 }
448 section.push_str(&format!("File: {rel} ({lines} lines)\n{fence}\n{content}"));
449 if !content.ends_with('\n') {
450 section.push('\n');
451 }
452 section.push_str(&fence);
453 section.push('\n');
454 used += tokens;
455 inlined += 1;
456 }
457 section
458}
459
460pub fn handle(task: &str, project_root: &str, crp_mode: CrpMode) -> (String, usize) {
462 lean_ctx::core::policy::runtime::with_project_source_view(project_root, || {
463 handle_in_view(task, project_root, crp_mode)
464 })
465 .unwrap_or_else(|reason| {
466 (
467 format!(
468 "ERROR: context withheld: source authority changed or could not be verified \
469 ({reason})"
470 ),
471 0,
472 )
473 })
474}
475
476fn handle_in_view(task: &str, project_root: &str, crp_mode: CrpMode) -> (String, usize) {
477 let task = task.trim();
478 if task.is_empty() {
479 return ("ERROR: task is required".to_string(), 0);
480 }
481
482 let protected = lean_ctx::core::policy::runtime::is_active();
483 let keywords = extract_keywords(task, 6);
484 let allow_secret = lean_ctx::core::roles::active_role().io.allow_secret_paths;
485
486 let mut out = String::new();
487 out.push_str(&format!("TASK: {task}\n"));
488 if keywords.is_empty() {
489 out.push_str("KEYWORDS: (none extracted — using full task for ranking)\n");
490 } else {
491 out.push_str(&format!("KEYWORDS: {}\n", keywords.join(", ")));
492 }
493
494 out.push_str("\n## Ranked files (local BM25)\n");
497 {
498 let _phase = lean_ctx::core::phase_timing::enter("ctx_compose: ranking");
499 out.push_str(&ranked_files_budgeted(task, project_root, crp_mode));
500 }
501 if protected {
502 out.push_str("\nStored graph and memory enrichment withheld: source authorization is unavailable. Fresh exact matches and symbols follow.");
505 }
506 out.push('\n');
507
508 let exact_phase = lean_ctx::core::phase_timing::enter("ctx_compose: exact matches");
512 let ranked_keywords = if protected {
513 keywords.clone()
514 } else {
515 order_by_specificity(&keywords, project_root)
516 };
517 if let Some(primary) = ranked_keywords
518 .iter()
519 .find(|keyword| is_code_identifier(keyword))
520 {
521 out.push_str(&format!("\n## Exact matches: '{primary}'\n"));
522 out.push_str(&exact_matches_section(
523 primary,
524 project_root,
525 crp_mode,
526 allow_secret,
527 ));
528 }
529
530 drop(exact_phase);
536 let symbols_phase = lean_ctx::core::phase_timing::enter("ctx_compose: symbol bodies");
537 use lean_ctx::core::context_packing::CoverageItem;
538 let mut snippets: Vec<String> = Vec::new();
539 let mut items: Vec<CoverageItem> = Vec::new();
540 for kw in &keywords {
541 if let Some((rendered, toks)) =
542 crate::tools::ctx_symbol::best_symbol_snippet_for_task(kw, task, project_root)
543 {
544 let mut terms: std::collections::HashSet<String> =
547 std::collections::HashSet::from([kw.clone()]);
548 for other in &keywords {
549 if other != kw && rendered.contains(other.as_str()) {
550 terms.insert(other.clone());
551 }
552 }
553 if let Some(index) = snippets.iter().position(|snippet| snippet == &rendered) {
554 items[index].terms.extend(terms);
555 continue;
556 }
557 items.push(CoverageItem {
558 terms,
559 cost: toks.max(1),
560 });
561 snippets.push(rendered);
562 }
563 }
564 if !items.is_empty() {
565 let selected = lean_ctx::core::context_packing::greedy_max_coverage(
566 &items,
567 symbol_budget_tokens(),
568 |_| 1.0,
569 );
570 let mut seen = std::collections::HashSet::new();
571 let mut header_written = false;
572 for idx in selected {
573 let rendered = snippets[idx].trim();
574 if rendered.is_empty() || !seen.insert(rendered.to_string()) {
575 continue;
576 }
577 if !header_written {
578 out.push_str("\n## Top symbols (bodies)\n");
579 header_written = true;
580 }
581 out.push_str(rendered);
582 out.push('\n');
583 }
584 }
585
586 drop(symbols_phase);
590
591 {
593 let _phase = lean_ctx::core::phase_timing::enter("ctx_compose: small files");
594 out.push_str(&small_files_section(&out, project_root, allow_secret));
595 }
596
597 if !protected {
601 use lean_ctx::core::context_kernel::context_dedup::dedup_kernel_blocks;
602 let _phase = lean_ctx::core::phase_timing::enter("ctx_compose: kernel enrichment");
603
604 let budget = kernel_supplement_budget(project_root);
605 if let Some(enrichment) =
606 lean_ctx::core::context_kernel::bridge::kernel_enrich(task, project_root, budget)
607 .filter(|enrichment| !enrichment.blocks.is_empty())
608 {
609 let blocks =
610 dedup_kernel_blocks(&enrichment.blocks, &mut std::collections::HashSet::new());
611 if !blocks.is_empty() {
612 out.push_str("\n## Context Kernel\n");
613 out.push_str(&blocks);
614 out.push('\n');
615 }
616 }
617 }
618
619 let sent = count_tokens(&out);
620 (out, sent)
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 #[test]
628 fn ranking_worker_keeps_original_source_policy_and_full_role() {
629 let _env = lean_ctx::core::data_dir::test_env_lock();
630 let policy = lean_ctx::core::policy::load(
631 "name = \"compose-worker\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
632 [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n\
633 [redaction]\ncustomer = \"ACC-1234\"\n",
634 )
635 .unwrap();
636 let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(policy));
637 let dir = tempfile::tempdir().unwrap();
638 std::fs::write(
639 dir.path().join("public.rs"),
640 "fn authenticate() { let customer = \"ACC-1234\"; }\n",
641 )
642 .unwrap();
643 std::fs::write(
644 dir.path().join("private.rs"),
645 "// CONFIDENTIAL\nfn authenticate_private() { let hidden = \"CLASSIFIED_CANARY\"; }\n",
646 )
647 .unwrap();
648 let root = dir.path().to_str().unwrap();
649 let rank = || {
650 lean_ctx::core::policy::runtime::with_project_source_view(root, || {
651 ranked_files_budgeted("authenticate", root, CrpMode::Off)
652 })
653 .unwrap()
654 };
655 let role = lean_ctx::core::roles::load_role("coder").unwrap();
656 lean_ctx::core::roles::with_test_active_role(role.clone(), || {
657 let ranked = rank();
658 assert!(ranked.contains("public.rs"), "{ranked}");
659 assert!(ranked.contains("REDACTED"), "{ranked}");
660 assert!(!ranked.contains("ACC-1234"));
661 assert!(!ranked.contains("private.rs"));
662 assert!(!ranked.contains("CLASSIFIED_CANARY"));
663 static ONE_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1);
664 let occupied = ONE_SLOT.try_acquire().unwrap();
665 let rank_with_limit = || {
666 lean_ctx::core::policy::runtime::with_project_source_view(root, || {
667 ranked_files_with_slots("authenticate", root, CrpMode::Off, &ONE_SLOT)
668 })
669 .unwrap()
670 };
671 let busy = rank_with_limit();
672 assert!(busy.contains("ranking busy"));
673 assert!(!busy.contains("public.rs"));
674 drop(occupied);
675 let recovered = rank_with_limit();
676 assert!(recovered.contains("public.rs"));
677 assert!(recovered.contains("REDACTED"));
678 assert!(!recovered.contains("CLASSIFIED_CANARY"));
679 });
680 let mut denied = role;
681 denied.tools.denied.push("ctx_search".into());
682 lean_ctx::core::roles::with_test_active_role(denied, || {
683 let ranked = rank();
684 assert!(ranked.starts_with("ERR:"), "{ranked}");
685 assert!(!ranked.contains("public.rs"));
686 });
687 }
688
689 #[test]
690 fn rank_by_doc_freq_puts_rare_identifier_first() {
691 let keywords = vec![
695 "OCPP".to_string(),
696 "GetMaxCurrent".to_string(),
697 "Current".to_string(),
698 ];
699 let doc_freqs = std::collections::HashMap::from([
700 ("ocpp".to_string(), 120),
701 ("current".to_string(), 400),
702 ("getmaxcurrent".to_string(), 3),
703 ]);
704 let ranked = rank_by_doc_freq(&keywords, &doc_freqs);
705 assert_eq!(ranked.first().unwrap(), "GetMaxCurrent");
706 assert_eq!(ranked.last().unwrap(), "Current");
707 }
708
709 #[test]
714 fn named_small_files_are_inlined_verbatim_within_budget() {
715 let _env = lean_ctx::core::data_dir::test_env_lock();
716 let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
717 let dir = tempfile::tempdir().unwrap();
718 std::fs::write(
719 dir.path().join("pyproject.toml"),
720 "[project]\nname = \"x\"\n",
721 )
722 .unwrap();
723 std::fs::create_dir(dir.path().join("pkg")).unwrap();
724 let cli = "import argparse\n\n\ndef main():\n return \"```\"\n";
725 std::fs::write(dir.path().join("pkg/cli.py"), cli).unwrap();
726 let big: String = (0..400).fold(String::new(), |mut acc, i| {
727 use std::fmt::Write as _;
728 let _ = writeln!(acc, "value_{i} = {i}");
729 acc
730 });
731 std::fs::write(dir.path().join("pkg/big.py"), &big).unwrap();
732 std::fs::write(dir.path().join(".env"), "TOKEN=abc\n").unwrap();
733 let composed = "## Ranked files (local BM25)\n\
734 pkg/big.py :: value_1 [Variable] (L2-2)\n\
735 pkg/cli.py :: main [Function] (L4-5)\n\
736 ## Exact matches: 'main'\n\
737 .env:1 TOKEN=abc\n\
738 pkg/cli.py:4 def main():\n";
739 let root = dir.path().to_str().unwrap();
740 let section = lean_ctx::core::policy::runtime::with_project_source_view(root, || {
741 small_files_section(composed, root, false)
742 })
743 .unwrap();
744 assert!(
745 section.contains(&format!("File: pkg/cli.py (5 lines)\n````\n{cli}````\n")),
746 "{section}"
747 );
748 assert!(!section.contains("value_399"), "{section}");
749 assert!(!section.contains("TOKEN=abc"), "{section}");
750 assert_eq!(section.matches("File: ").count(), 1, "{section}");
751 }
752
753 #[test]
754 fn rank_by_doc_freq_sinks_absent_tokens_and_is_stable() {
755 let keywords = vec![
758 "absent".to_string(),
759 "alpha".to_string(),
760 "beta".to_string(),
761 ];
762 let doc_freqs =
763 std::collections::HashMap::from([("alpha".to_string(), 5), ("beta".to_string(), 5)]);
764 let ranked = rank_by_doc_freq(&keywords, &doc_freqs);
765 assert_eq!(ranked, vec!["alpha", "beta", "absent"]);
766 }
767
768 #[test]
769 fn rank_prefers_code_identifier_over_rarer_prose_word() {
770 let keywords = vec!["measurand".to_string(), "GetMaxCurrent".to_string()];
775 let doc_freqs = std::collections::HashMap::from([
776 ("measurand".to_string(), 4),
777 ("getmaxcurrent".to_string(), 30),
778 ]);
779 let ranked = rank_by_doc_freq(&keywords, &doc_freqs);
780 assert_eq!(ranked.first().unwrap(), "GetMaxCurrent");
781 }
782
783 #[test]
784 fn is_code_identifier_classifies_camel_snake_vs_prose_and_acronym() {
785 assert!(is_code_identifier("GetMaxCurrent"));
786 assert!(is_code_identifier("CurrentGetter"));
787 assert!(is_code_identifier("get_max_current"));
788 assert!(!is_code_identifier("Current"));
790 assert!(!is_code_identifier("OCPP"));
791 assert!(!is_code_identifier("charger"));
792 }
793
794 #[test]
795 fn extract_keywords_drops_stopwords_and_short_tokens() {
796 let kw = extract_keywords("How does the BM25Index cache work for ctx_search?", 6);
797 assert!(kw.contains(&"BM25Index".to_string()));
798 assert!(kw.contains(&"cache".to_string()));
799 assert!(kw.contains(&"ctx_search".to_string()));
800 assert!(!kw.iter().any(|k| k == "the" || k == "How" || k == "for"));
801 }
802
803 #[test]
804 fn extract_keywords_dedups_and_caps() {
805 let kw = extract_keywords("alpha alpha beta gamma delta epsilon zeta eta", 3);
806 assert_eq!(kw.len(), 3);
807 assert_eq!(kw[0], "alpha");
808 }
809
810 #[test]
811 fn exact_matches_choose_specific_identifier_not_first_broad_keyword() {
812 let keywords = extract_keywords(
813 "OCPP charger GetMaxCurrent Current.Offered measurand CurrentGetter",
814 6,
815 );
816 assert!(keywords.iter().any(|keyword| keyword == "GetMaxCurrent"));
817 assert!(keywords.iter().any(|keyword| is_code_identifier(keyword)));
818 assert!(!is_code_identifier("OCPP"));
819
820 let prose = extract_keywords("Fix semantic ranking exact matches", 6);
821 assert!(prose.iter().all(|keyword| !is_code_identifier(keyword)));
822 }
823
824 #[test]
827 fn exact_matches_rank_definition_and_code_above_prose() {
828 let _env = lean_ctx::core::data_dir::test_env_lock();
831 let dir = tempfile::tempdir().unwrap();
832 let root = dir.path();
833 std::fs::write(root.join("AGENTS.md"), "use widget_factory\n".repeat(30)).unwrap();
834 std::fs::create_dir_all(root.join("src")).unwrap();
835 std::fs::write(
836 root.join("src/lib.rs"),
837 "pub fn widget_factory() -> u8 { 1 }\n",
838 )
839 .unwrap();
840 std::fs::write(
841 root.join("src/main.rs"),
842 "fn main() { lib::widget_factory(); }\n",
843 )
844 .unwrap();
845 let root_str = root.to_str().unwrap();
846 let out = lean_ctx::core::policy::runtime::with_project_source_view(root_str, || {
847 exact_matches_section("widget_factory", root_str, CrpMode::Off, false)
848 })
849 .expect("source view");
850
851 let defs = out.find("Definitions:").expect("definition section");
852 let uses = out.find("Uses in code:").expect("code section");
853 assert!(defs < uses, "{out}");
854 assert!(out.contains("lib.rs:1"), "definition found: {out}");
855 assert!(out.contains("main.rs:1"), "use found: {out}");
856 assert!(
857 !out.contains("AGENTS.md"),
858 "prose only when code has none: {out}"
859 );
860
861 let prose_only =
862 lean_ctx::core::policy::runtime::with_project_source_view(root_str, || {
863 exact_matches_section("use", root_str, CrpMode::Off, false)
864 })
865 .expect("source view");
866 assert!(prose_only.contains("AGENTS.md"), "{prose_only}");
867 }
868
869 #[test]
870 fn empty_task_is_rejected() {
871 let (out, tok) = handle(" ", "/tmp", CrpMode::Off);
872 assert!(out.starts_with("ERROR"));
873 assert_eq!(tok, 0);
874 }
875
876 #[test]
877 fn handle_withholds_context_when_project_authority_cannot_be_verified() {
878 let root = tempfile::tempdir().unwrap();
879 let missing = root.path().join("missing-project");
880 let (output, tokens) = handle(
881 "find authentication bugs",
882 missing.to_str().unwrap(),
883 CrpMode::Tdd,
884 );
885 assert_eq!(tokens, 0);
886 assert!(output.starts_with("ERROR"));
887 assert!(output.contains("source authority"));
888 assert!(!output.contains("TASK:"));
889 }
890
891 #[test]
892 fn deferred_ranking_note_is_deterministic_and_has_no_timing() {
893 let a = deferred_ranking_note();
896 let b = deferred_ranking_note();
897 assert_eq!(a, b, "deferred note must be byte-stable across calls");
898 assert!(
899 !a.contains("elapsed"),
900 "deferred note must not embed timing data: {a}"
901 );
902 }
903
904 #[test]
905 fn deferred_note_does_not_promise_cached_or_late_delivery() {
906 let note = deferred_ranking_note();
907 assert!(note.contains("late ranking output is discarded"));
908 assert!(note.contains("independently admitted"));
909 assert!(!note.contains("warming"));
910 assert!(!note.contains("next call"));
911 }
912}