cqs 1.25.0

Code intelligence and RAG for AI agents. Semantic search, call graphs, impact analysis, type dependencies, and smart context assembly — in single tool calls. 54 languages + L5X/L5K PLC exports, 91.2% Recall@1 (BGE-large), 0.951 MRR (296 queries). Local ML, GPU-accelerated.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
//! Scout — pre-investigation dashboard for task planning
//!
//! Given a task description, searches for relevant code, groups by file,
//! and returns signatures + caller/test counts + staleness + relevant notes.
//! Optimized for planning, not reading.

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use crate::store::{ChunkSummary, NoteSummary, SearchFilter};
use crate::{normalize_slashes, AnalysisError, Embedder, Store};

/// Role classification for chunks in scout results
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ChunkRole {
    /// High-relevance function likely needing modification (score >= 0.5)
    ModifyTarget,
    /// Test that may need updating
    TestToUpdate,
    /// Lower-relevance dependency
    Dependency,
}

impl ChunkRole {
    /// Stable string representation for JSON serialization.
    pub fn as_str(&self) -> &'static str {
        match self {
            ChunkRole::ModifyTarget => "modify_target",
            ChunkRole::TestToUpdate => "test_to_update",
            ChunkRole::Dependency => "dependency",
        }
    }
}

/// A chunk in the scout result with hints
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScoutChunk {
    /// Function/class/etc. name
    pub name: String,
    /// Type of code element
    pub chunk_type: crate::language::ChunkType,
    /// Function signature
    pub signature: String,
    /// Starting line number
    pub line_start: u32,
    /// Role classification
    pub role: ChunkRole,
    /// Number of callers
    pub caller_count: usize,
    /// Number of tests reaching this function
    pub test_count: usize,
    /// Semantic search score (0.0-1.0)
    pub search_score: f32,
}

/// A file group in the scout result
#[derive(Debug, Clone, serde::Serialize)]
pub struct FileGroup {
    /// File path
    #[serde(serialize_with = "crate::serialize_path_normalized")]
    pub file: PathBuf,
    /// Aggregate relevance score
    pub relevance_score: f32,
    /// Chunks in this file
    pub chunks: Vec<ScoutChunk>,
    /// Whether the file is stale (modified since last index)
    pub is_stale: bool,
}

/// Summary counts
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScoutSummary {
    pub total_files: usize,
    pub total_functions: usize,
    pub untested_count: usize,
    pub stale_count: usize,
}

/// Complete scout result
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScoutResult {
    pub file_groups: Vec<FileGroup>,
    pub relevant_notes: Vec<NoteSummary>,
    pub summary: ScoutSummary,
}

/// Minimum relative gap (%) between consecutive scores to split ModifyTarget
/// from Dependency. Below this, all non-test chunks are treated as a single
/// cluster and only the top result becomes a ModifyTarget.
const MIN_GAP_RATIO: f32 = 0.10;

/// Default number of search results for scout.
pub const DEFAULT_SCOUT_SEARCH_LIMIT: usize = 15;

/// Default minimum search score threshold for scout.
pub const DEFAULT_SCOUT_SEARCH_THRESHOLD: f32 = 0.2;

/// Options for customizing scout behavior.
#[derive(Debug, Clone)]
pub struct ScoutOptions {
    /// Number of search results to retrieve (default: 15)
    pub search_limit: usize,
    /// Minimum search score threshold (default: 0.2)
    pub search_threshold: f32,
    /// Minimum relative gap between consecutive scores to split ModifyTarget
    /// from Dependency (default: 0.10). Lower values create more ModifyTargets.
    pub min_gap_ratio: f32,
}

impl Default for ScoutOptions {
    /// Creates a new instance with default configuration values.
    ///
    /// # Returns
    ///
    /// A `Self` instance initialized with default search limit, search threshold, and minimum gap ratio constants.
    fn default() -> Self {
        Self {
            search_limit: DEFAULT_SCOUT_SEARCH_LIMIT,
            search_threshold: DEFAULT_SCOUT_SEARCH_THRESHOLD,
            min_gap_ratio: MIN_GAP_RATIO,
        }
    }
}

/// Run scout analysis for a task description.
///
/// Uses default search parameters. For custom parameters, use [`scout_with_options`].
pub fn scout(
    store: &Store,
    embedder: &Embedder,
    task: &str,
    root: &Path,
    limit: usize,
) -> Result<ScoutResult, AnalysisError> {
    scout_with_options(store, embedder, task, root, limit, &ScoutOptions::default())
}

/// Run scout analysis with configurable search parameters.
pub fn scout_with_options(
    store: &Store,
    embedder: &Embedder,
    task: &str,
    root: &Path,
    limit: usize,
    opts: &ScoutOptions,
) -> Result<ScoutResult, AnalysisError> {
    let _span = tracing::info_span!("scout", task_len = task.len(), limit).entered();
    let query_embedding = embedder.embed_query(task)?;
    let graph = store.get_call_graph()?;
    let test_chunks = match store.find_test_chunks() {
        Ok(tc) => tc,
        Err(e) => {
            tracing::warn!(error = %e, "Failed to load test chunks, scout will skip test analysis");
            std::sync::Arc::new(Vec::new())
        }
    };
    scout_core(&ScoutResources {
        store,
        query_embedding: &query_embedding,
        task,
        root,
        limit,
        opts,
        graph: &graph,
        test_chunks: &test_chunks,
    })
}

/// Pre-loaded resources for scout_core, avoiding repeated lookups.
pub(crate) struct ScoutResources<'a> {
    pub store: &'a Store,
    pub query_embedding: &'a crate::Embedding,
    pub task: &'a str,
    pub root: &'a Path,
    pub limit: usize,
    pub opts: &'a ScoutOptions,
    pub graph: &'a crate::store::CallGraph,
    pub test_chunks: &'a [ChunkSummary],
}

/// Core scout implementation accepting pre-loaded resources.
///
/// Use this when you already have the call graph and test chunks loaded
/// (e.g., from `cqs task` which shares them across phases).
pub(crate) fn scout_core(res: &ScoutResources<'_>) -> Result<ScoutResult, AnalysisError> {
    let store = res.store;
    let query_embedding = res.query_embedding;
    let task = res.task;
    let root = res.root;
    let limit = res.limit;
    let opts = res.opts;
    let graph = res.graph;
    let test_chunks = res.test_chunks;
    let _span = tracing::info_span!("scout_core", %task, limit).entered();

    // 1. Search
    let filter = SearchFilter {
        enable_rrf: false, // RRF off by default — pure cosine is faster + higher R@1 on expanded eval
        query_text: task.to_string(),
        ..SearchFilter::default()
    };

    let results = store.search_filtered(
        query_embedding,
        &filter,
        opts.search_limit,
        opts.search_threshold,
    )?;

    tracing::debug!(search_results = results.len(), "Scout search complete");

    if results.is_empty() {
        return Ok(ScoutResult {
            file_groups: Vec::new(),
            relevant_notes: Vec::new(),
            summary: ScoutSummary {
                total_files: 0,
                total_functions: 0,
                untested_count: 0,
                stale_count: 0,
            },
        });
    }

    // 2. Group by file
    let mut file_map: HashMap<PathBuf, Vec<(f32, &ChunkSummary)>> = HashMap::new();
    for r in &results {
        file_map
            .entry(r.chunk.file.clone())
            .or_default()
            .push((r.score, &r.chunk));
    }

    // 3. Batch caller/callee counts
    let all_names: Vec<&str> = results.iter().map(|r| r.chunk.name.as_str()).collect();
    let caller_counts = match store.get_caller_counts_batch(&all_names) {
        Ok(c) => c,
        Err(e) => {
            tracing::warn!(error = %e, "Failed to fetch caller counts");
            HashMap::new()
        }
    };

    // 5. Check staleness
    let origins: Vec<String> = file_map
        .keys()
        .map(|p| p.to_string_lossy().into_owned())
        .collect();
    let origin_refs: Vec<&str> = origins.iter().map(|s| s.as_str()).collect();
    let stale_set = match store.check_origins_stale(&origin_refs, root) {
        Ok(s) => s,
        Err(e) => {
            tracing::warn!(error = %e, "Failed to check staleness");
            HashSet::new()
        }
    };

    // 6. Compute dynamic modify-target threshold via gap detection.
    // Scores naturally cluster: items matching both semantic + keyword rank
    // higher than keyword-only or semantic-only. Find the largest relative gap
    // in the sorted scores and split there. Scale-independent — works on
    // cosine (0-1), RRF (~0.01-0.03), or any future scoring.
    let modify_threshold = compute_modify_threshold(&results, opts.min_gap_ratio);
    tracing::debug!(modify_threshold, "Gap-based threshold computed");

    // 7. Batch-compute hints for all result chunks (PERF-20: single forward BFS)
    let all_chunk_names: Vec<&str> = results.iter().map(|r| r.chunk.name.as_str()).collect();
    let hints_batch =
        crate::impact::compute_hints_batch(graph, test_chunks, &all_chunk_names, &caller_counts);
    let hints_map: std::collections::HashMap<&str, &crate::impact::FunctionHints> = all_chunk_names
        .iter()
        .zip(hints_batch.iter())
        .map(|(&name, hints)| (name, hints))
        .collect();

    // 8. Build file groups
    let mut groups: Vec<FileGroup> = file_map
        .into_iter()
        .map(|(file, chunks)| {
            let relevance_score = chunks.iter().map(|(s, _)| s).sum::<f32>() / chunks.len() as f32;
            let is_stale = stale_set.contains(&file.to_string_lossy().to_string());

            let scout_chunks: Vec<ScoutChunk> = chunks
                .iter()
                .map(|(score, chunk)| {
                    let default_hints = crate::impact::FunctionHints {
                        caller_count: 0,
                        test_count: 0,
                    };
                    let hints = hints_map
                        .get(chunk.name.as_str())
                        .copied()
                        .unwrap_or(&default_hints);

                    let role = classify_role(
                        *score,
                        &chunk.name,
                        &chunk.file.to_string_lossy(),
                        modify_threshold,
                    );

                    ScoutChunk {
                        name: chunk.name.clone(),
                        chunk_type: chunk.chunk_type,
                        signature: chunk.signature.clone(),
                        line_start: chunk.line_start,
                        role,
                        caller_count: hints.caller_count,
                        test_count: hints.test_count,
                        search_score: *score,
                    }
                })
                .collect();

            FileGroup {
                file: file.strip_prefix(root).unwrap_or(&file).to_path_buf(),
                relevance_score,
                chunks: scout_chunks,
                is_stale,
            }
        })
        .collect();

    // Sort by relevance, take top N
    groups.sort_by(|a, b| b.relevance_score.total_cmp(&a.relevance_score));
    groups.truncate(limit);

    // 7. Find relevant notes by mention overlap
    let result_files: HashSet<String> = groups
        .iter()
        .map(|g| crate::rel_display(&g.file, root))
        .collect();

    let relevant_notes = find_relevant_notes(store, &result_files);

    // 8. Build summary
    let total_functions: usize = groups.iter().map(|g| g.chunks.len()).sum();
    let untested_count: usize = groups
        .iter()
        .flat_map(|g| &g.chunks)
        .filter(|c| c.test_count == 0 && c.role != ChunkRole::TestToUpdate)
        .count();
    let stale_count = groups.iter().filter(|g| g.is_stale).count();

    Ok(ScoutResult {
        summary: ScoutSummary {
            total_files: groups.len(),
            total_functions,
            untested_count,
            stale_count,
        },
        file_groups: groups,
        relevant_notes,
    })
}

/// Find the natural score boundary between high-relevance (ModifyTarget) and
/// low-relevance (Dependency) chunks using gap detection.
///
/// Sorts non-test scores descending, finds the largest relative gap between
/// consecutive scores, and returns the score at the top of the gap (i.e. the
/// lowest score that still qualifies as a ModifyTarget).
/// Guarantees at least 1 ModifyTarget, at most half the non-test results.
/// If no clear gap exists (all gaps < 10%), only the top result qualifies.
/// Tied scores at the threshold are included as ModifyTargets.
fn compute_modify_threshold(results: &[crate::store::SearchResult], min_gap_ratio: f32) -> f32 {
    // RB-18: Early return for empty results — no modify targets possible
    if results.is_empty() {
        return f32::MAX;
    }

    let mut scores: Vec<f32> = results
        .iter()
        .filter(|r| !crate::is_test_chunk(&r.chunk.name, &r.chunk.file.to_string_lossy()))
        .map(|r| r.score)
        .collect();
    scores.sort_by(|a, b| b.total_cmp(a));

    if scores.len() <= 1 {
        return scores.first().copied().unwrap_or(f32::MAX);
    }

    // Search the top half for the largest relative gap
    let max_targets = scores.len() / 2;
    let mut best_gap = 0.0f32;
    let mut split_at = 0; // index of last item in the top cluster

    for i in 0..max_targets.min(scores.len() - 1) {
        if scores[i] > 0.0 {
            let gap = (scores[i] - scores[i + 1]) / scores[i];
            if gap > best_gap {
                best_gap = gap;
                split_at = i;
            }
        }
    }

    // No clear gap → only top result is a ModifyTarget
    if best_gap < min_gap_ratio {
        return scores[0];
    }

    scores[split_at]
}

/// Classify a chunk's role based on score, name/file, and dynamic threshold.
fn classify_role(score: f32, name: &str, file: &str, modify_threshold: f32) -> ChunkRole {
    if crate::is_test_chunk(name, file) {
        ChunkRole::TestToUpdate
    } else if score >= modify_threshold {
        ChunkRole::ModifyTarget
    } else {
        ChunkRole::Dependency
    }
}

/// Find notes whose mentions overlap with result file paths.
///
/// Matches when a mention is a suffix of a result file path (e.g., mention "search.rs"
/// matches result "src/search.rs") at a path-component boundary.
/// This avoids false matches from short concept words like "audit" or "security".
fn find_relevant_notes(store: &Store, result_files: &HashSet<String>) -> Vec<NoteSummary> {
    let all_notes = match store.list_notes_summaries() {
        Ok(n) => n,
        Err(e) => {
            tracing::warn!(error = %e, "Failed to list notes");
            return Vec::new();
        }
    };

    all_notes
        .into_iter()
        .filter(|note| {
            note.mentions
                .iter()
                .any(|m| result_files.iter().any(|f| note_mention_matches_file(m, f)))
        })
        .collect()
}

/// Check if a note mention matches a result file path.
///
/// Only file-like mentions (containing '.' or '/') are considered.
/// Match requires the file path to end with the mention at a path-component
/// boundary (preceded by '/' or at start of string).
fn note_mention_matches_file(mention: &str, file: &str) -> bool {
    let mention = normalize_slashes(mention);
    let file = normalize_slashes(file);
    if !mention.contains('.') && !mention.contains('/') {
        return false;
    }
    file.ends_with(&mention)
        && (file.len() == mention.len() || file.as_bytes()[file.len() - mention.len() - 1] == b'/')
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_classify_role_modify_target() {
        assert_eq!(
            classify_role(0.6, "search_filtered", "src/search.rs", 0.5),
            ChunkRole::ModifyTarget
        );
        assert_eq!(
            classify_role(0.5, "do_something", "src/lib.rs", 0.5),
            ChunkRole::ModifyTarget
        );
    }

    #[test]
    fn test_classify_role_dependency() {
        assert_eq!(
            classify_role(0.49, "helper_fn", "src/lib.rs", 0.5),
            ChunkRole::Dependency
        );
        assert_eq!(
            classify_role(0.3, "utility", "src/lib.rs", 0.5),
            ChunkRole::Dependency
        );
    }

    #[test]
    fn test_classify_role_test() {
        // Name-based test detection
        assert_eq!(
            classify_role(0.9, "test_search", "src/lib.rs", 0.5),
            ChunkRole::TestToUpdate
        );
        assert_eq!(
            classify_role(0.3, "test_helper", "src/lib.rs", 0.5),
            ChunkRole::TestToUpdate
        );
        // v1.22.0 audit AC-4: `TestSuite` in non-test paths is a production
        // type, not a test. After the `is_test_chunk` fix, this correctly
        // classifies as a modification target, not a test to update.
        assert_eq!(
            classify_role(0.8, "TestSuite", "src/lib.rs", 0.5),
            ChunkRole::ModifyTarget
        );
        // But `TestSuite` in an actual test path IS a test:
        assert_eq!(
            classify_role(0.8, "TestSuite", "tests/lib.rs", 0.5),
            ChunkRole::TestToUpdate
        );
        // File-based test detection
        assert_eq!(
            classify_role(0.9, "helper_fn", "tests/integration.rs", 0.5),
            ChunkRole::TestToUpdate
        );
    }

    /// Creates a mock SearchResult for testing purposes with the specified name, file path, and relevance score.
    ///
    /// # Arguments
    ///
    /// * `name` - The identifier and name of the code chunk
    /// * `file` - The file path as a string where the chunk is located
    /// * `score` - The relevance score as a floating-point number
    ///
    /// # Returns
    ///
    /// A SearchResult containing a ChunkSummary with default/placeholder values for a Rust function chunk and the provided score.
    fn mock_result(name: &str, file: &str, score: f32) -> crate::store::SearchResult {
        crate::store::SearchResult {
            chunk: ChunkSummary {
                id: name.to_string(),
                file: std::path::PathBuf::from(file),
                language: crate::language::Language::Rust,
                chunk_type: crate::language::ChunkType::Function,
                name: name.to_string(),
                signature: String::new(),
                content: String::new(),
                doc: None,
                line_start: 1,
                line_end: 10,
                parent_id: None,
                parent_type_name: None,
                content_hash: String::new(),
                window_idx: None,
            },
            score,
        }
    }

    #[test]
    fn test_compute_modify_threshold_clear_gap() {
        // RRF-like scores: top 3 in both semantic+FTS, bottom 3 in one only
        let results = vec![
            mock_result("a", "src/a.rs", 0.033),
            mock_result("b", "src/b.rs", 0.031),
            mock_result("c", "src/c.rs", 0.030),
            mock_result("d", "src/d.rs", 0.016), // big gap here
            mock_result("e", "src/e.rs", 0.015),
            mock_result("f", "src/f.rs", 0.014),
        ];
        let threshold = compute_modify_threshold(&results, MIN_GAP_RATIO);
        // Should split at the gap: 0.030 is the cutoff
        assert!(threshold >= 0.030);
        assert!(threshold <= 0.033);
    }

    #[test]
    fn test_compute_modify_threshold_no_gap() {
        // Nearly uniform scores — no clear gap
        let results = vec![
            mock_result("a", "src/a.rs", 0.020),
            mock_result("b", "src/b.rs", 0.019),
            mock_result("c", "src/c.rs", 0.018),
            mock_result("d", "src/d.rs", 0.017),
        ];
        let threshold = compute_modify_threshold(&results, MIN_GAP_RATIO);
        // All gaps < 10%, only top result qualifies
        assert!((threshold - 0.020).abs() < f32::EPSILON);
    }

    #[test]
    fn test_compute_modify_threshold_single() {
        let results = vec![mock_result("a", "src/a.rs", 0.05)];
        assert!((compute_modify_threshold(&results, MIN_GAP_RATIO) - 0.05).abs() < f32::EPSILON);
    }

    #[test]
    fn test_compute_modify_threshold_empty() {
        assert_eq!(compute_modify_threshold(&[], MIN_GAP_RATIO), f32::MAX);
    }

    #[test]
    fn test_compute_modify_threshold_skips_tests() {
        // test_foo is a test — should be excluded from threshold computation
        let results = vec![
            mock_result("test_foo", "src/a.rs", 0.050), // test, ignored
            mock_result("bar", "src/b.rs", 0.020),
            mock_result("baz", "src/c.rs", 0.010),
        ];
        let threshold = compute_modify_threshold(&results, MIN_GAP_RATIO);
        // Only bar and baz considered; gap between 0.020 and 0.010 is 50%
        assert!((threshold - 0.020).abs() < f32::EPSILON);
    }

    #[test]
    fn test_compute_modify_threshold_cosine_scale() {
        // Works on cosine 0-1 scale too — scale-independent
        let results = vec![
            mock_result("a", "src/a.rs", 0.95),
            mock_result("b", "src/b.rs", 0.90),
            mock_result("c", "src/c.rs", 0.50), // big gap
            mock_result("d", "src/d.rs", 0.45),
        ];
        let threshold = compute_modify_threshold(&results, MIN_GAP_RATIO);
        assert!(threshold >= 0.90);
    }

    #[test]
    fn test_note_mention_matches_file() {
        // Positive: suffix at path boundary
        assert!(note_mention_matches_file("search.rs", "src/search.rs"));
        assert!(note_mention_matches_file("src/search.rs", "src/search.rs"));
        assert!(note_mention_matches_file("cli/mod.rs", "src/cli/mod.rs"));
        assert!(note_mention_matches_file("mod.rs", "src/cli/mod.rs"));

        // Negative: not at path boundary (partial filename)
        assert!(!note_mention_matches_file("od.rs", "src/cli/mod.rs"));
        assert!(!note_mention_matches_file("earch.rs", "src/search.rs"));

        // Negative: not file-like (no '.' or '/')
        assert!(!note_mention_matches_file("audit", "src/audit.rs"));
        assert!(!note_mention_matches_file("search", "src/search.rs"));

        // Negative: mention longer than file
        assert!(!note_mention_matches_file(
            "extra/src/search.rs",
            "search.rs"
        ));

        // Edge: exact match
        assert!(note_mention_matches_file("src/scout.rs", "src/scout.rs"));

        // Edge: mention with '/' but no match
        assert!(!note_mention_matches_file(
            "other/search.rs",
            "src/search.rs"
        ));
    }

    #[test]
    fn test_note_mention_matches_file_backslash() {
        assert!(note_mention_matches_file("scout.rs", "src\\scout.rs"));
        assert!(note_mention_matches_file("cli\\mod.rs", "src\\cli\\mod.rs"));
        assert!(!note_mention_matches_file("od.rs", "src\\cli\\mod.rs"));
    }

    #[test]
    fn test_scout_summary_nonzero() {
        // Verify struct fields are stored and accessible (not tautologically zero)
        let summary = ScoutSummary {
            total_files: 3,
            total_functions: 15,
            untested_count: 4,
            stale_count: 2,
        };
        assert_eq!(summary.total_files, 3);
        assert_eq!(summary.total_functions, 15);
        assert_eq!(summary.untested_count, 4);
        assert_eq!(summary.stale_count, 2);
    }

    #[test]
    fn test_scout_result_serialization_empty() {
        let result = ScoutResult {
            file_groups: Vec::new(),
            relevant_notes: Vec::new(),
            summary: ScoutSummary {
                total_files: 0,
                total_functions: 0,
                untested_count: 0,
                stale_count: 0,
            },
        };
        let json = serde_json::to_value(&result).unwrap();
        assert_eq!(json["file_groups"].as_array().unwrap().len(), 0);
        assert_eq!(json["relevant_notes"].as_array().unwrap().len(), 0);
        assert_eq!(json["summary"]["total_files"], 0);
    }

    #[test]
    fn test_chunk_role_equality() {
        assert_eq!(ChunkRole::ModifyTarget, ChunkRole::ModifyTarget);
        assert_ne!(ChunkRole::ModifyTarget, ChunkRole::Dependency);
        assert_ne!(ChunkRole::TestToUpdate, ChunkRole::Dependency);
    }

    #[test]
    fn test_chunk_role_as_str() {
        assert_eq!(ChunkRole::ModifyTarget.as_str(), "modify_target");
        assert_eq!(ChunkRole::TestToUpdate.as_str(), "test_to_update");
        assert_eq!(ChunkRole::Dependency.as_str(), "dependency");
    }

    // TC-4: compute_modify_threshold with all-test-chunk inputs
    #[test]
    fn test_compute_modify_threshold_all_tests() {
        let results = vec![
            mock_result("test_a", "src/a.rs", 0.9),
            mock_result("test_b", "src/b.rs", 0.8),
            mock_result("test_c", "src/c.rs", 0.7),
        ];
        let threshold = compute_modify_threshold(&results, MIN_GAP_RATIO);
        // All chunks are tests → no non-test scores → should return f32::MAX
        assert_eq!(threshold, f32::MAX);
    }

    // TC-6: classify_role at exact threshold with test names
    #[test]
    fn test_classify_role_exact_threshold_test_name() {
        // Test name at exact threshold — test detection takes priority over score
        assert_eq!(
            classify_role(0.5, "test_foo", "src/lib.rs", 0.5),
            ChunkRole::TestToUpdate
        );
        // Non-test name at exact threshold — should be ModifyTarget
        assert_eq!(
            classify_role(0.5, "process_data", "src/lib.rs", 0.5),
            ChunkRole::ModifyTarget
        );
        // Test name below threshold — still TestToUpdate
        assert_eq!(
            classify_role(0.3, "test_bar", "src/lib.rs", 0.5),
            ChunkRole::TestToUpdate
        );
    }

    // TC-10: note_mention_matches_file with empty strings
    #[test]
    fn test_note_mention_matches_file_empty() {
        assert!(!note_mention_matches_file("", "src/lib.rs"));
        assert!(!note_mention_matches_file("lib.rs", ""));
        assert!(!note_mention_matches_file("", ""));
    }
}