codescout 0.15.0

High-performance coding agent toolkit MCP server
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
//! Legibility scan engine — ranks refactor candidates from usage.db friction
//! and the AST symbol index. Pure: (db conn, project root) -> ranked candidates.
//! Phase 2a of docs/superpowers/specs/2026-06-13-dzo-friction-probes-design.md.

use crate::lsp::symbols::{SymbolInfo, SymbolKind};
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;

/// A structural legibility defect kind — the entry gate. A candidate must have one.
/// (`NameCollision` retired 2026-06-13 — only language-agnostic, AST-measurable
/// defects remain; rationale in `docs/adrs/2026-06-13-drop-name-collision-defect.md`.)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Defect {
    OverBudgetBody,
    UnMappableFile,
}

/// Tier 1 = biting now (has recorder friction); Tier 2 = latent (structural only).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Tier {
    BitingNow,
    Latent,
}

impl Tier {
    /// 1 for biting-now, 2 for latent — the spec's numeric tier.
    pub fn rank(self) -> u8 {
        match self {
            Tier::BitingNow => 1,
            Tier::Latent => 2,
        }
    }
}

/// Observed cost from the recorder, per target. `retries` is reserved (0 in v1 —
/// same-input-repeat detection is a follow-up).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Friction {
    pub truncations: u32,
    pub retries: u32,
    pub code_class_edit_fails: u32,
    pub other: u32,
    pub sessions: u32,
}

impl Friction {
    pub fn is_empty(&self) -> bool {
        self.truncations == 0
            && self.retries == 0
            && self.code_class_edit_fails == 0
            && self.other == 0
    }
    /// score = 3*truncations + 2*retries + 2*code_class_edit_fails + 1*other.
    /// Infra-class err_family never reaches `code_class_edit_fails` (excluded in the
    /// recorder query), so tool-class noise cannot inflate a code candidate.
    pub fn score(&self) -> u32 {
        3 * self.truncations + 2 * self.retries + 2 * self.code_class_edit_fails + self.other
    }
}

/// A raw structural finding from the index lane, before scoring/tiering.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StructuralDefect {
    pub rel_file: String,
    pub name_path: String, // "(file)" for UnMappableFile
    pub defect: Defect,
    pub tokens: usize,
    pub lines: u32,
}

/// A parsed source file: its rel path, its source lines, and its symbol tree.
pub struct FileSymbols {
    pub rel_file: String,
    pub lines: Vec<String>,
    pub symbols: Vec<SymbolInfo>,
}

/// A ranked refactor candidate — the engine's output unit.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Candidate {
    pub key: String, // "<rel_file>::<name_path>"
    pub rel_file: String,
    pub name_path: String,
    pub defect: Defect,
    pub tier: Tier,
    pub tokens: usize,
    pub budget: usize,
    pub lines: u32,
    pub friction: Friction,
    pub score: u32,
}

fn is_body_bearing(kind: &SymbolKind) -> bool {
    matches!(
        kind,
        SymbolKind::Function | SymbolKind::Method | SymbolKind::Constructor
    )
}

/// Recurse the symbol tree, collecting body-bearing symbols.
fn collect_bodies<'a>(syms: &'a [SymbolInfo], out: &mut Vec<&'a SymbolInfo>) {
    for s in syms {
        if is_body_bearing(&s.kind) {
            out.push(s);
        }
        collect_bodies(&s.children, out);
    }
}

/// The source text of a symbol's body, plus its line count. Empty when the range
/// is degenerate or out of bounds.
fn body_text(lines: &[String], sym: &SymbolInfo) -> (String, u32) {
    if lines.is_empty() {
        return (String::new(), 0);
    }
    let start = sym.range_start_line.unwrap_or(sym.start_line) as usize;
    let end = (sym.end_line as usize).min(lines.len() - 1);
    if start > end {
        return (String::new(), 0);
    }
    (lines[start..=end].join("\n"), (end - start + 1) as u32)
}

/// Index-lane detector: function/method bodies that exceed the inline budget
/// (so `symbols(include_body=true)` truncates them).
pub fn over_budget_bodies(files: &[FileSymbols]) -> Vec<StructuralDefect> {
    let mut out = Vec::new();
    for f in files {
        let mut bodies = Vec::new();
        collect_bodies(&f.symbols, &mut bodies);
        for sym in bodies {
            let (body, lines) = body_text(&f.lines, sym);
            if !body.is_empty() && crate::tools::exceeds_inline_limit(&body) {
                out.push(StructuralDefect {
                    rel_file: f.rel_file.clone(),
                    name_path: sym.name_path.clone(),
                    defect: Defect::OverBudgetBody,
                    tokens: body.len() / 4,
                    lines,
                });
            }
        }
    }
    out
}

/// Recurse the symbol tree, collecting ALL symbols (any kind).
fn collect_all<'a>(syms: &'a [SymbolInfo], out: &mut Vec<&'a SymbolInfo>) {
    for s in syms {
        out.push(s);
        collect_all(&s.children, out);
    }
}

/// Estimated byte size of a `symbols(path)` overview: ~one line per symbol,
/// dominated by the name_path + the optional detail (signature), plus a fixed
/// per-line overhead (kind label, line range, indentation).
fn overview_bytes(files_syms: &[&SymbolInfo]) -> usize {
    const PER_SYMBOL_OVERHEAD: usize = 24;
    files_syms
        .iter()
        .map(|s| PER_SYMBOL_OVERHEAD + s.name_path.len() + s.detail.as_deref().map_or(0, str::len))
        .sum()
}

/// Index-lane detector: a file whose `symbols(path)` overview would exceed the
/// inline budget (can't be mapped in one call). Driven by symbol count/size, NOT
/// line count — a cleanly-mapped long file is left alone (verified: longer files
/// comprehend better; the hazard is total context, not within-file length).
pub fn un_mappable_files(files: &[FileSymbols]) -> Vec<StructuralDefect> {
    let mut out = Vec::new();
    for f in files {
        let mut all = Vec::new();
        collect_all(&f.symbols, &mut all);
        let bytes = overview_bytes(&all);
        if bytes > crate::tools::MAX_INLINE_TOKENS * 4 {
            out.push(StructuralDefect {
                rel_file: f.rel_file.clone(),
                name_path: "(file)".to_string(),
                defect: Defect::UnMappableFile,
                tokens: bytes / 4,
                lines: f.lines.len() as u32,
            });
        }
    }
    out
}

/// Walk the project (gitignore-aware), parse every recognized source file's symbols.
pub fn parse_project(root: &Path) -> Vec<FileSymbols> {
    let mut out = Vec::new();
    for entry in ignore::WalkBuilder::new(root)
        .hidden(true)
        .git_ignore(true)
        .build()
        .flatten()
    {
        if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
            continue;
        }
        let path = entry.path();
        let Some(lang) = crate::ast::detect_language(path) else {
            continue;
        };
        let Ok(source) = std::fs::read_to_string(path) else {
            continue;
        };
        let Ok(symbols) =
            crate::ast::parser::extract_symbols_from_source(&source, Some(lang), path)
        else {
            continue; // unparseable / unsupported → skip, never fail the whole scan
        };
        let rel_file = path
            .strip_prefix(root)
            .unwrap_or(path)
            .to_string_lossy()
            .to_string();
        let lines = source.lines().map(str::to_string).collect();
        out.push(FileSymbols {
            rel_file,
            lines,
            symbols,
        });
    }
    out
}

/// Run the structural detectors over the parsed project. (NameCollision retired
/// 2026-06-13 — see the `Defect` docs + ADR; only AST-measurable defects remain.)
pub fn index_lane(root: &Path) -> Vec<StructuralDefect> {
    let files = parse_project(root);
    let mut defects = over_budget_bodies(&files);
    defects.extend(un_mappable_files(&files));
    defects
}

/// `err_family` values that indicate a code/extractor-shape problem (count toward
/// the score). Infra families (lsp_disconnect, …) are tool-class and excluded.
const CODE_FAMILIES: &str = "'ast_extent_fail','ambiguous_name_path','replace_dropped_sibling'";
const INFRA_FAMILIES: &str =
    "'lsp_disconnect','lsp_index_locked','mux_startup_fail','lsp_not_running'";

/// Recorder lane: aggregate per-`friction_target` cost from usage.db, scoped to
/// this repo by `project_root` (the F-1 cross-project contamination fix).
pub fn recorder_lane(
    conn: &rusqlite::Connection,
    project_root: &str,
) -> rusqlite::Result<HashMap<String, Friction>> {
    let sql = format!(
        "SELECT friction_target,
                SUM(CASE WHEN overflowed = 1 THEN 1 ELSE 0 END),
                SUM(CASE WHEN err_family IN ({code}) THEN 1 ELSE 0 END),
                SUM(CASE WHEN outcome != 'success'
                          AND (err_family IS NULL OR err_family NOT IN ({code}, {infra}))
                         THEN 1 ELSE 0 END),
                COUNT(DISTINCT cc_session_id)
         FROM tool_calls
         WHERE project_root = ?1 AND friction_target IS NOT NULL AND friction_target != ''
         GROUP BY friction_target",
        code = CODE_FAMILIES,
        infra = INFRA_FAMILIES,
    );
    let mut stmt = conn.prepare(&sql)?;
    let rows = stmt.query_map([project_root], |r| {
        Ok((
            r.get::<_, String>(0)?,
            Friction {
                truncations: r.get::<_, i64>(1)? as u32,
                retries: 0,
                code_class_edit_fails: r.get::<_, i64>(2)? as u32,
                other: r.get::<_, i64>(3)? as u32,
                sessions: r.get::<_, i64>(4)? as u32,
            },
        ))
    })?;
    let mut map = HashMap::new();
    for row in rows {
        let (k, fr) = row?;
        map.insert(k, fr);
    }
    Ok(map)
}

/// Combine structural defects with recorder friction → tiered, scored, ranked
/// candidates. A candidate's friction is matched by its name_path, falling back to
/// its rel_file (for un-mappable files, whose `friction_target` is the path).
pub fn score_and_rank(
    structural: Vec<StructuralDefect>,
    friction: &HashMap<String, Friction>,
) -> Vec<Candidate> {
    let mut cands: Vec<Candidate> = structural
        .into_iter()
        .map(|d| {
            let fr = friction
                .get(&d.name_path)
                .or_else(|| friction.get(&d.rel_file))
                .cloned()
                .unwrap_or_default();
            let tier = if fr.is_empty() {
                Tier::Latent
            } else {
                Tier::BitingNow
            };
            let score = fr.score();
            Candidate {
                key: format!("{}::{}", d.rel_file, d.name_path),
                rel_file: d.rel_file,
                name_path: d.name_path,
                defect: d.defect,
                tier,
                tokens: d.tokens,
                budget: crate::tools::MAX_INLINE_TOKENS,
                lines: d.lines,
                friction: fr,
                score,
            }
        })
        .collect();

    // Tier 1 before Tier 2; within tier 1 by score desc; ties and tier 2 by tokens
    // over budget (proxy: tokens) desc; final tie-break by key for determinism.
    cands.sort_by(|a, b| {
        a.tier
            .rank()
            .cmp(&b.tier.rank())
            .then(b.score.cmp(&a.score))
            .then(b.tokens.cmp(&a.tokens))
            .then(a.key.cmp(&b.key))
    });
    cands
}

/// The engine entry point: index lane + recorder lane → ranked candidates.
pub fn scan(
    conn: &rusqlite::Connection,
    root: &Path,
    project_root: &str,
) -> anyhow::Result<Vec<Candidate>> {
    let structural = index_lane(root);
    let friction = recorder_lane(conn, project_root)?;
    Ok(score_and_rank(structural, &friction))
}

/// Re-measure a single target's current cost (tokens, lines), independent of whether
/// it is still a defect. Used by Phase 2b to fill the `after` delta when a candidate
/// auto-closes (its defect is gone). For a symbol key, measures the body; for an
/// un-mappable file (`name_path == "(file)"`), measures the overview size.
pub fn measure_target(
    files: &[FileSymbols],
    rel_file: &str,
    name_path: &str,
) -> Option<(usize, u32)> {
    let f = files.iter().find(|f| f.rel_file == rel_file)?;
    if name_path == "(file)" {
        let mut all = Vec::new();
        collect_all(&f.symbols, &mut all);
        return Some((overview_bytes(&all) / 4, f.lines.len() as u32));
    }
    let mut all = Vec::new();
    collect_all(&f.symbols, &mut all);
    let sym = all.iter().find(|s| s.name_path == name_path)?;
    let (body, lines) = body_text(&f.lines, sym);
    Some((body.len() / 4, lines))
}

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

    #[test]
    fn friction_score_and_emptiness() {
        let empty = Friction::default();
        assert!(empty.is_empty());
        assert_eq!(empty.score(), 0);

        let f = Friction {
            truncations: 14,
            retries: 0,
            code_class_edit_fails: 1,
            other: 2,
            sessions: 2,
        };
        assert!(!f.is_empty());
        // 3*14 + 2*0 + 2*1 + 1*2 = 46
        assert_eq!(f.score(), 46);
    }

    fn sym(name_path: &str, kind: SymbolKind, start: u32, end: u32) -> SymbolInfo {
        SymbolInfo {
            name: name_path
                .rsplit('/')
                .next()
                .unwrap_or(name_path)
                .to_string(),
            name_path: name_path.to_string(),
            kind,
            file: std::path::PathBuf::from("x.rs"),
            start_line: start,
            end_line: end,
            range_start_line: None,
            start_col: 0,
            children: vec![],
            detail: None,
        }
    }

    fn file_with(rel: &str, body_lines: usize, syms: Vec<SymbolInfo>) -> FileSymbols {
        // each line is 200 bytes so `body_lines` lines ≈ body_lines*200 bytes
        FileSymbols {
            rel_file: rel.to_string(),
            lines: (0..body_lines).map(|_| "x".repeat(200)).collect(),
            symbols: syms,
        }
    }

    #[test]
    fn over_budget_bodies_flags_only_over_budget_functions() {
        // big fn spans lines 0..=70 → ~70*201 ≈ 14k bytes > 10k budget
        let big = sym("Foo/big", SymbolKind::Method, 0, 70);
        // small fn spans lines 0..=5 → ~6*201 ≈ 1.2k bytes < budget
        let small = sym("Foo/small", SymbolKind::Method, 0, 5);
        let files = vec![file_with("src/foo.rs", 71, vec![big, small])];
        let defects = over_budget_bodies(&files);
        assert_eq!(defects.len(), 1, "only the big body");
        assert_eq!(defects[0].name_path, "Foo/big");
        assert_eq!(defects[0].defect, Defect::OverBudgetBody);
        assert!(defects[0].tokens > crate::tools::MAX_INLINE_TOKENS);
    }

    #[test]
    fn over_budget_ignores_non_body_kinds() {
        // a Struct spanning many lines is NOT a body-bearing symbol
        let s = sym("BigStruct", SymbolKind::Struct, 0, 70);
        let files = vec![file_with("src/foo.rs", 71, vec![s])];
        assert!(over_budget_bodies(&files).is_empty());
    }

    #[test]
    fn un_mappable_files_flags_overview_over_budget_not_line_count() {
        // Many symbols → estimated overview exceeds the budget.
        let many: Vec<SymbolInfo> = (0..400)
            .map(|i| {
                sym(
                    &format!("Mod/sym_{i:04}_with_a_longish_name"),
                    SymbolKind::Function,
                    i,
                    i,
                )
            })
            .collect();
        let big_map = file_with("src/huge.rs", 400, many);

        // A long file (1500 lines) with FEW symbols maps cleanly → NOT flagged.
        // (Encodes the verified longer-files-better finding: line count is not a trigger.)
        let long_clean = file_with(
            "src/long_clean.rs",
            1500,
            vec![
                sym("A/f", SymbolKind::Function, 0, 700),
                sym("A/g", SymbolKind::Function, 701, 1499),
            ],
        );

        let defects = un_mappable_files(&[big_map, long_clean]);
        assert_eq!(defects.len(), 1, "only the many-symbol file");
        assert_eq!(defects[0].rel_file, "src/huge.rs");
        assert_eq!(defects[0].name_path, "(file)");
        assert_eq!(defects[0].defect, Defect::UnMappableFile);
    }

    #[test]
    fn index_lane_finds_over_budget_body_in_real_file() {
        let dir = tempfile::tempdir().unwrap();
        // a Rust file with one huge function (each line padded so the body exceeds budget)
        let mut src = String::from("fn huge() {\n");
        for i in 0..200 {
            src.push_str(&format!("    let v{i} = \"{}\";\n", "x".repeat(80)));
        }
        src.push_str("}\n");
        std::fs::write(dir.path().join("huge.rs"), src).unwrap();

        let defects = index_lane(dir.path());
        assert!(
            defects
                .iter()
                .any(|d| d.defect == Defect::OverBudgetBody && d.name_path.contains("huge")),
            "expected an over-budget-body defect for `huge`, got: {defects:?}"
        );
    }

    #[test]
    fn index_lane_does_not_flag_name_collisions() {
        // Guard for docs/adrs/2026-06-13-drop-name-collision-defect.md: a file with two
        // same-name methods (the classic inherent + trait `fmt` collision) must produce
        // ZERO defects. name_collision was retired — its disambiguator is per-language
        // and the qualified symbol form already resolves the ambiguity, so flagging it
        // is per-language-incorrect (TypeScript declaration merging is benign, not a bug).
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(
            tmp.path().join("s.rs"),
            "struct S;\n\
             impl std::fmt::Debug for S {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        Ok(())\n    }\n}\n\
             impl std::fmt::Display for S {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        Ok(())\n    }\n}\n",
        )
        .unwrap();
        let defects = index_lane(tmp.path());
        assert!(
            defects.is_empty(),
            "a name collision must not be flagged after NameCollision was retired: {defects:?}"
        );
    }

    #[test]
    fn recorder_lane_aggregates_friction_and_filters_by_project_root() {
        use crate::usage::db::{open_db, write_record};
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
        let conn = open_db(dir.path()).unwrap();

        // 2 truncations on the same target, this repo
        for _ in 0..2 {
            write_record(
                &conn,
                "symbols",
                1,
                "success",
                true,
                None,
                "cs",
                None,
                "s1",
                None,
                None,
                Some("ccs1"),
                Some("Foo/bar"),
                Some(1000),
                None,
                Some("/repo"),
            )
            .unwrap();
        }
        // 1 code-class edit fail on the same target, this repo
        write_record(
            &conn,
            "edit_code",
            1,
            "error",
            false,
            Some("ambiguous name_path \"Foo/bar\" matches 2 symbols"),
            "cs",
            None,
            "s1",
            None,
            None,
            Some("ccs1"),
            Some("Foo/bar"),
            None,
            Some("ambiguous_name_path"),
            Some("/repo"),
        )
        .unwrap();
        // a FOREIGN-project row for the same target — must be excluded (F-1)
        write_record(
            &conn,
            "symbols",
            1,
            "success",
            true,
            None,
            "cs",
            None,
            "s9",
            None,
            None,
            Some("ccs9"),
            Some("Foo/bar"),
            Some(9999),
            None,
            Some("/other-repo"),
        )
        .unwrap();

        let map = recorder_lane(&conn, "/repo").unwrap();
        let fr = map.get("Foo/bar").expect("Foo/bar present");
        assert_eq!(
            fr.truncations, 2,
            "foreign-repo truncation must be excluded"
        );
        assert_eq!(fr.code_class_edit_fails, 1);
        assert_eq!(fr.sessions, 1);
        assert_eq!(fr.score(), 3 * 2 + 2); // 8 = 3·2 (code-class fails) + 2·1 (session)
        assert!(!map.contains_key(""), "empty friction_target excluded");
    }

    #[test]
    fn score_and_rank_tiers_and_orders() {
        let structural = vec![
            // biting-now: has friction
            StructuralDefect {
                rel_file: "src/a.rs".into(),
                name_path: "A/hot".into(),
                defect: Defect::OverBudgetBody,
                tokens: 4000,
                lines: 242,
            },
            // latent: no friction, bigger body
            StructuralDefect {
                rel_file: "src/b.rs".into(),
                name_path: "B/cold".into(),
                defect: Defect::OverBudgetBody,
                tokens: 6000,
                lines: 331,
            },
        ];
        let mut friction = HashMap::new();
        friction.insert(
            "A/hot".to_string(),
            Friction {
                truncations: 5,
                ..Default::default()
            },
        );

        let ranked = score_and_rank(structural, &friction);
        assert_eq!(ranked.len(), 2);
        // biting-now (A/hot) ranks above latent (B/cold) despite smaller body
        assert_eq!(ranked[0].name_path, "A/hot");
        assert_eq!(ranked[0].tier, Tier::BitingNow);
        assert_eq!(ranked[0].key, "src/a.rs::A/hot");
        assert_eq!(ranked[0].score, 15); // 3*5
        assert_eq!(ranked[1].name_path, "B/cold");
        assert_eq!(ranked[1].tier, Tier::Latent);
    }

    #[test]
    fn scan_end_to_end_ranks_a_real_over_budget_body() {
        use crate::usage::db::{open_db, write_record};
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();

        // a real over-budget function in a source file
        let mut src = String::from("fn huge() {\n");
        for i in 0..200 {
            src.push_str(&format!("    let v{i} = \"{}\";\n", "x".repeat(80)));
        }
        src.push_str("}\n");
        std::fs::write(dir.path().join("huge.rs"), src).unwrap();

        let conn = open_db(dir.path()).unwrap();
        // friction targeting the function's name_path (whatever the extractor calls it: "huge")
        write_record(
            &conn,
            "symbols",
            1,
            "success",
            true,
            None,
            "cs",
            None,
            "s1",
            None,
            None,
            Some("ccs1"),
            Some("huge"),
            Some(3500),
            None,
            Some(&dir.path().to_string_lossy()),
        )
        .unwrap();

        let cands = scan(&conn, dir.path(), &dir.path().to_string_lossy()).unwrap();
        assert!(
            cands
                .iter()
                .any(|c| c.defect == Defect::OverBudgetBody && c.name_path.contains("huge")),
            "expected ranked over-budget candidate for huge: {cands:?}"
        );
    }

    #[test]
    fn measure_target_returns_body_size_for_a_symbol() {
        let big = sym("Foo/big", SymbolKind::Method, 0, 70);
        let files = vec![file_with("src/foo.rs", 71, vec![big])];
        let (tokens, lines) = measure_target(&files, "src/foo.rs", "Foo/big").unwrap();
        assert!(tokens > crate::tools::MAX_INLINE_TOKENS);
        assert_eq!(lines, 71);
        assert!(measure_target(&files, "src/foo.rs", "Foo/missing").is_none());
    }
}