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
//! `refactoring-targets` analysis.
//!
//! Ranks files by return-on-investment for refactoring: the intersection of
//! low code health and high development activity, divided by inspection
//! effort. `priority = (structural_risk × hotspot_score) / max(loc, floor)` —
//! an effort-aware ranking so a small, dense, churning, unhealthy file
//! outranks a large one with the same raw risk. Reuses the `code-health`
//! composite (which also materialises the per-file biomarker table) and the
//! `hotspots` activity signal; joins them per file.
//!
//! **Join semantic**: results contain only files present in *both* the
//! code-health output (files with parseable complexity surviving `min_revs`)
//! and the hotspots output; because the code-health file set is a strict
//! subset of the hotspots file set, code-health is the binding constraint —
//! a churning file with no parseable complexity (unsupported, vendored, or
//! skipped by the tree-sitter walker) will not appear in the output.
//!
//! Research basis: effort-aware defect ranking (risk per unit inspection
//! effort; Popt / `PofB20`) with an EA-Z-style size floor to avoid tiny-file
//! ranking artifacts.
use std::collections::HashMap;
use crate::analyses::code_health::run_code_health;
use crate::analyses::hotspots::run_hotspots;
use crate::analyses::query::query_map_collect;
use crate::facts::FactsDb;
use crate::{Options, Result};
/// EA-Z-style effort floor: files smaller than this are treated as this many
/// lines when dividing risk by effort, so a 3-line file cannot dominate the
/// ranking on a near-zero denominator.
///
/// 25 lines is roughly the size below which a "file" is typically a stub, a
/// re-export, a small constants/config module, or a trivial wrapper — units
/// where line count is a poor proxy for inspection effort. Flooring the
/// denominator there neutralises the `risk / tiny_loc` blow-up for such
/// micro-files while staying small enough not to distort genuinely small but
/// real modules (whose LOC already exceeds the floor).
const EA_Z_FLOOR: u32 = 25;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RefactoringTargetRow {
pub path: String,
/// `(structural_risk × hotspot_score) / max(loc, EA_Z_FLOOR)`. Higher = refactor sooner.
pub priority: f64,
/// `structural_risk × hotspot_score` (health deficit × hotspotness), pre-effort.
pub combined_risk: f64,
pub structural_risk: f64,
pub hotspot_score: f64,
pub revisions: u32,
pub loc: u32,
/// Dominant biomarker smell for this file, or `"none"` if no biomarker is recorded.
pub dominant_type: String,
pub band: String,
/// `ManualUp` baseline rank: 1-based, ascending by `loc` (smallest file = 1),
/// ties broken by `path`. Assigned over the full set before any truncation.
pub manual_up_rank: u32,
}
/// Run the `refactoring-targets` analysis. Returns rows ranked by `priority`
/// DESC (worst-ROI-debt first), truncated to `opts.rows_limit`.
///
/// # Errors
///
/// Returns [`crate::CodeLoreError::Analysis`] on `DuckDB` errors.
#[tracing::instrument(name = "refactoring-targets", skip_all, fields(min_revs = opts.min_revs))]
pub fn run_refactoring_targets(db: &FactsDb, opts: &Options) -> Result<Vec<RefactoringTargetRow>> {
// Row-limit discipline: the inner analyses must see the FULL file set, or
// ranking would be computed over a truncated input. Truncate only the
// final sorted output.
let full = opts.with_no_row_limit();
// run_code_health ALSO materialises `code_health_biomarkers_v1` on the
// connection (used by biomarker enrichment for the dominant biomarker).
let health = run_code_health(db, &full)?;
let hotspots = run_hotspots(db, &full)?;
// Per-file LOC (effort). Raw `complexity_metrics` — the grouped table omits `loc`.
let loc_by_path: HashMap<String, u32> = query_map_collect(
db,
"SELECT path, MAX(loc) AS loc FROM complexity_metrics WHERE loc IS NOT NULL GROUP BY path",
[],
"refactoring-targets:loc",
|r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1).map(|v| u32::try_from(v).unwrap_or(0))?,
))
},
)?
.into_iter()
.collect();
// Index hotspots by path for the join.
let hs_by_path: HashMap<&str, &crate::analyses::hotspots::HotspotRow> =
hotspots.iter().map(|h| (h.path.as_str(), h)).collect();
// Dominant biomarker per file: highest-intensity smell, ties broken by
// smell name so the pick is deterministic. Reads the temp table that
// run_code_health materialised above. `intensity > 0` excludes the
// zero-intensity rows the SQL biomarkers emit for the least-risky file in
// each language (a `PERCENT_RANK` of 0), so a file with no real smell is
// reported as "none" rather than a spurious biomarker name.
let dominant_by_path: HashMap<String, String> = query_map_collect(
db,
"SELECT path, smell FROM ( \
SELECT path, smell, \
ROW_NUMBER() OVER (PARTITION BY path ORDER BY intensity DESC, smell ASC) AS rn \
FROM code_health_biomarkers_v1 \
WHERE intensity > 0 \
) WHERE rn = 1",
[],
"refactoring-targets:dominant",
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
)?
.into_iter()
.collect();
let mut rows: Vec<RefactoringTargetRow> = health
.iter()
.filter_map(|h| {
// Only files that are BOTH scored for health AND appear as hotspots.
let hs = hs_by_path.get(h.path.as_str())?;
// True file LOC (0 = no LOC data); the EA-Z effort floor is applied
// only inside the priority denominator, never to the reported value.
let loc = loc_by_path.get(&h.path).copied().unwrap_or(0);
let combined_risk = h.structural_risk * hs.hotspot_score;
let priority = combined_risk / f64::from(loc.max(EA_Z_FLOOR));
Some(RefactoringTargetRow {
path: h.path.clone(),
priority,
combined_risk,
structural_risk: h.structural_risk,
hotspot_score: hs.hotspot_score,
revisions: hs.revisions,
loc,
dominant_type: dominant_by_path
.get(&h.path)
.cloned()
.unwrap_or_else(|| "none".to_owned()),
band: h.band.clone(),
manual_up_rank: 0,
})
})
.collect();
// Deterministic sort: priority DESC, then path ASC as a stable tie-break.
rows.sort_by(|a, b| {
b.priority
.partial_cmp(&a.priority)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.path.cmp(&b.path))
});
// ManualUp baseline: rank by ascending size (smallest first). Computed over
// the full set so the rank is stable regardless of the priority truncation.
let mut by_size: Vec<usize> = (0..rows.len()).collect();
by_size.sort_by(|&i, &j| {
rows[i]
.loc
.cmp(&rows[j].loc)
.then_with(|| rows[i].path.cmp(&rows[j].path))
});
for (rank, &idx) in by_size.iter().enumerate() {
rows[idx].manual_up_rank = u32::try_from(rank + 1).unwrap_or(u32::MAX);
}
if let Some(limit) = opts.rows_limit {
rows.truncate(limit as usize);
}
Ok(rows)
}