codelore 0.27.3

CodeLore — Behavioral Code Analyzer CLI
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
//! `codelore explain` — metric catalogue and per-file evidence dossiers.
//!
//! With no argument, lists every supported topic. With a topic name, prints its
//! citation, formula, and source. With an argument that names no topic, resolves
//! it to a repo-relative source file and prints that file's deterministic
//! evidence dossier — and, with `--llm`, an advisory grounded narrative.

use std::io::Write as _;

use anyhow::{Context, Result};
use codelore_lib::cli_api::facts::FactsDb;
use codelore_lib::cli_api::repo::{GixRepo, Repo as _};
use codelore_lib::cli_api::{CodeLoreError, Options};

use crate::args;

/// Print formula + citation + SQL for the named metric or analysis.
/// With no topic, lists every supported topic. Makes the auditable-
/// formulas brand promise tactile on the CLI side — `codelore
/// explain hotspot-score` is the answer to "why does this file have
/// score 8.4?".
#[allow(clippy::too_many_lines)] // catalogue table — splitting would hurt readability
pub(crate) fn run_explain_cmd(args: &args::ExplainArgs) -> Result<()> {
    let topics: &[(&str, &str, &str, &str)] = &[
        (
            "hotspot-score",
            "Tornhill 2018 — Software Design X-Rays",
            "percentile_rank(revisions) × percentile_rank(cognitive) × (100 − cognitive_health) / 4. Range [0, 10]. cognitive_health here is the hotspots analysis's own inline proxy — 100 × (1 − 0.40 × normalize(cognitive)), bounded [60, 100] — NOT the code-health analysis's 8-smell composite score; see the code-health topic for that distinct metric.",
            "See analyses/hotspots.rs::SQL (file_revs + file_complexity + joined CTEs).",
        ),
        (
            "code-health",
            "code-health composite: biomarker structural risk (Complex/Large Method, God Class, DRY, Shotgun Surgery, Deep Nesting, Many Args, Complex Conditional) fused with behavioral signal (Nagappan & Ball 2005 churn + Mockus & Herbsleb 2002 ownership); coupling centrality enters once via the Shotgun Surgery biomarker (Tornhill 2018); self-relative percentile banding (Alves/Ypma/Visser 2010) plus an additive corpus-relative percentile when a calibration artifact is active, each corpus percentile paired with a Wilson 95% confidence interval that reflects the reference pool's sampling uncertainty (not measurement error in the file)",
            "100 × (1 − 0.50·structural_risk − 0.30·churn − 0.20·ownership_fv), where structural_risk is a weighted sum of biomarker intensities (complex-method 0.22, god-class 0.18, large-method 0.12, dry 0.12, shotgun-surgery 0.12, deep-nesting 0.10, many-args 0.07, complex-conditional 0.07); band from structural_risk thresholds (≥0.55 red, ≥0.28 yellow, else green); percentile = per-language PERCENT_RANK of structural_risk.",
            "See analyses/code_health.rs.",
        ),
        (
            "refactoring-targets",
            "effort-aware refactoring priority: (code-health structural_risk × hotspot_score) ÷ inspection effort, with a ManualUp baseline (Popt / PofB20 framing)",
            "priority = (structural_risk × hotspot_score) / max(loc, 25). Ranked DESC. `manual_up_rank` ranks the same files by ascending LOC (the \"inspect the small dense files first\" baseline the composite is meant to beat); `dominant_type` is the file's highest-intensity biomarker.",
            "See analyses/refactoring_targets.rs.",
        ),
        (
            "mi",
            "Coleman 1994 + SEI 1997",
            "171 − 5.2·log₂(V) − 0.23·CC − 16.2·log₂(SLOC) + 50·sin(√(2.4·comments%)). file-level `kind='unit'` entry.",
            "Surfaced by rust-code-analysis via codelore-rca.",
        ),
        (
            "coupling-density",
            "Newman 2010 §6.10 — graph density",
            "edges / (V·(V−1)/2) where V is the candidate node set (files with revs ≥ min_revs) and edges are Fisher-significant coupling pairs.",
            "See analyses/coupling.rs::density.",
        ),
        (
            "hotspots",
            "Tornhill 2015 + Bird et al. 2011",
            "Per-file behavioural risk surface: revisions × max(cognitive) × the inline cognitive-health proxy (distinct from the code-health composite score). The flagship CodeLore analysis.",
            "See analyses/hotspots.rs.",
        ),
        (
            "hotspot-velocity",
            "Change-acceleration early warning (recent vs baseline churn)",
            "Per file: acceleration = recent_per_week − baseline_per_week over a 30-day recent window vs the 90 days before it, anchored at MAX(commits.date). Positive = heating up (becoming a hotspot before its all-time count shows it); negative = cooling down.",
            "See analyses/hotspot_velocity.rs.",
        ),
        (
            "god-classes",
            "Brown et al. 1998 *AntiPatterns* §3.1 + Riel 1996 *Object-Oriented Design Heuristics*",
            "(cognitive / 100.0) × (fan_in + fan_out). Ranks files where all three pull up.",
            "See analyses/god_classes.rs.",
        ),
        (
            "architecture-roles",
            "Baldwin, MacCormack & Rusnak 2014 — Hidden Structure (Research Policy 43:8)",
            "Per-file Core/Shared/Control/Periphery from transitive visibility fan-in (vfi) / fan-out (vfo) on the import graph: Core = the largest cyclic group; Shared = vfi≥core, vfo<core; Control = vfi<core, vfo≥core; Periphery = both below. reach_pct = vfo/n×100; mean(vfo/n) = MacCormack propagation cost.",
            "See analyses/architecture_roles.rs + analyses/import_graph.rs::reachability.",
        ),
        (
            "instability",
            "Martin 1994 — OO Design Quality Metrics (Clean Architecture 2017)",
            "Per file: afferent coupling ca (files importing it / in-degree), efferent coupling ce (files it imports / out-degree), instability I = ce/(ca+ce) in [0,1]. 0 = stable (depended-on, depends on nothing), 1 = unstable. Resolved import graph; Abstractness/Distance need symbol data and are out of scope.",
            "See analyses/instability.rs.",
        ),
        (
            "cycle-health",
            "behavioral heat + extraction candidate per import cycle",
            "Per non-trivial SCC of the resolved import graph: heat_pct = the cycle \
             members' share of repo LOC churn over the trailing --window-days window \
             (same anchoring as effort-exposure); verdict = live when at least one \
             member appears in a window commit (a zero-LOC touch still counts), fossil \
             otherwise; extract_candidate = the member whose trial removal minimises \
             the largest surviving SCC of the induced subgraph (Tarjan per member, ties \
             by fewest surviving cyclic nodes then lexicographic path); \
             predicted_pc_drop = whole-graph MacCormack propagation-cost delta if the \
             candidate were extracted. Trial removal and the prediction run only for \
             cycles of ≤ 64 members; above that bound the prediction is absent and the \
             candidate falls back to the highest in-cycle degree.",
            "See analyses/cycle_health.rs.",
        ),
        (
            "defect-validation",
            "Śliwerski, Zimmermann & Zeller 2005 (SZZ) + Kim et al. 2006 (AG-SZZ)",
            "Reads an own-repo defect-calibration artifact (built by `codelore \
             calibrate-defects`) and reports its evidence as flat (metric, value) \
             rows: the band table (share of defect-introducing changes that landed \
             in files red / yellow / green at the time), AUC and precision@k of \
             HEAD structural_risk against the defect-implicated file labels, mining \
             tallies, and the weight-tuning decision with both validation AUCs. \
             Association, not causation — a defect touching a red file is evidence \
             the score ranks it high, not proof the score caused the defect. Reads \
             the artifact only; without one it emits zero rows and a stderr hint.",
            "See analyses/defect_validation.rs + defect_calibration/.",
        ),
        (
            "architecture-metrics",
            "Lakos 1996 (CCD/ACD/NCCD) + MacCormack/Rusnak/Baldwin 2006/2014",
            "Repo-level (metric, value) rows: propagation_cost = density of the transitive-closure matrix; acd = mean transitive dependency set size; nccd = CCD / balanced-binary-tree CCD (<1 flat, >1 layered, >2 likely cyclic); dependency_cycles, largest_cycle; architecture_type = hierarchical / core-periphery / multi-core. When a calibration artifact carries repo-level corpus pools, corpus_percentile:propagation_cost / :cycle_file_share rows locate this repo against the corpus, each paired with a Wilson 95% confidence interval (:ci_low / :ci_high) for the finite pool's sampling uncertainty; corpus_n reports the pool size.",
            "See analyses/architecture_metrics.rs.",
        ),
        (
            "architecture-trend",
            "Architectural decay over the commit sequence",
            "Recomputes propagation cost, dependency-cycle count and largest tangle at up to 12 historical revs (evenly spaced across history), rebuilding the import graph in memory at each by reading + resolving source blobs at that rev. Shows whether structure is decaying and roughly when it started. Heavier than the SQL-only analyses (it re-parses source per sample); computed on demand, never cached.",
            "See analyses/architecture_trend.rs.",
        ),
        (
            "health-trend",
            "Repo health (architectural + code + combined) over the commit sequence",
            "Computes three 0-100 scores at up to 12 historical revs (evenly spaced): \
             architectural health (structural — propagation cost + dependency tangle), \
             code health (the rev-parameterized code-health engine with duplication \
             excluded, averaged over files), and their equal blend. Bands: green >= 70, \
             yellow 40-69, red < 40. Rebuilds the import graph + re-scans complexity per \
             sample, so it is heavier than SQL-only analyses; computed on demand, never \
             cached.",
            "See analyses/health_trend.rs.",
        ),
        (
            "effort-exposure",
            "Engineering effort distribution across code-health bands",
            "For each code-health band (red / yellow / green) reports the percentage of \
             files, SLOC, trailing-window commits, and LOC churn in that band. Answers \
             the hero KPI question: are we spending most effort fighting fires in red code \
             or extending healthy green code? Commit-share Wilson 95% CI is included per \
             band. Window anchors to the repo's last commit date (not wall-clock) via \
             --window-days (default 90).",
            "See analyses/effort_exposure.rs.",
        ),
        (
            "code-familiarity",
            "Decayed-knowledge familiarity score for the active team",
            "Computes what fraction of SLOC is actively known by current contributors \
             (authors with ≥1 commit in the trailing window). Uses exponentially-decayed \
             knowledge shares (Jabrayilzade et al., ICSE-SEIP 2022). Also reports islands \
             percentage: SLOC in files where one person holds ≥80% of knowledge with no \
             meaningful backup. Low familiarity or high islands percentage signals knowledge \
             risk. Verdict threshold configurable via [gates] code_familiarity_min in \
             .codelore-thresholds.toml (default 70.0).",
            "See analyses/code_familiarity.rs.",
        ),
        (
            "team-composition",
            "Contribution-span tenure buckets with behavioral veteran gate and onboarding velocity",
            "Buckets each author by contribution span (last − first commit): onboarded \
             (<90 d), experienced (90–364 d), veteran (≥365 d). Veterans who have not \
             touched a breadth of files comparable to the current 80%-core set are capped \
             at 'experienced' (veteran_breadth_ok = false). Also reports onboarding_weeks: \
             how many weeks from an author's first commit to their first week in the weekly \
             80%-core set. Authors whose first commit falls within the project's first 12 \
             weeks (founders) receive NULL for onboarding_weeks.",
            "See analyses/team_composition.rs.",
        ),
        (
            "coordination-needs",
            "Per-file coordination overhead: fragmentation, interleave, co-change entropy",
            "For each file reports: knowledge fragmentation (1 − HHI, 0 = single owner, \
             near 1 = evenly spread knowledge); author-switch interleave between adjacent \
             commits (0 = always same author, 1 = always alternating); and co-change graph \
             entropy contribution (EASE 2025, arXiv 2504.18511; window-scoped, commits \
             touching >30 files excluded). Tier: single (1 author) | low (frag<0.25) | \
             medium | high (frag≥0.50 AND interleave≥0.50). Joined with code-health band \
             so high-fragmentation files in the red band surface first.",
            "See analyses/coordination_needs.rs.",
        ),
        (
            "marginal-owner-risk",
            "Ownership concentration × code-health fusion: files where active authors have shallow familiarity",
            "For each file in the yellow or red health band, reports the maximum knowledge \
             share held by any author who committed within window_days. Risk tiers: high \
             (red band AND top active share <0.10); elevated ((red AND <0.30) OR (yellow \
             AND <0.10)). Rows that do not meet either threshold are excluded. The \
             ownership × code-quality interaction is correlational, not causal \
             (Palomba et al., EASE 2023, arXiv 2304.11636).",
            "See analyses/marginal_owner_risk.rs.",
        ),
        (
            "release-cadence",
            "Inter-release gap statistics from git tags (median, IQR, trend)",
            "Filters tags by --release-tag-glob (default 'v*'), then computes the \
             number of days between consecutive release tags. Emits one row per \
             matched tag (date, days_since_prev) plus a synthetic '__summary__' \
             row carrying the median gap, IQR, and a trend label: 'accelerating' \
             (negative OLS slope < −0.1 d/release), 'slowing' (slope > +0.1), or \
             'stable' (within ±0.1). Tags are proxies for releases, not \
             deployments; cadence reflects tagging discipline as much as actual \
             release velocity. First tag always has no predecessor gap.",
            "See analyses/release_cadence.rs.",
        ),
        (
            "delivery-metrics",
            "Repo-level delivery flow distributions: batch size, branch duration, rework, lead-proxy, and landed-by-other (p50/p75/p90)",
            "Six percentile distributions over merge units and commits: batch_size_files \
             (distinct paths per merge), batch_size_loc (LOC churn per merge), \
             branch_duration_hours (merge date − earliest branch-side author date), \
             rework_pct (hunk-overlap within --rework-window-days, approximate), \
             lead_proxy_hours (author→committer date gap, positive only, non-merge commits), \
             and landed_by_other_pct (share of non-merge commits where committer_email != \
             author_email, case-normalized — a peer-review/gatekeeper proxy; commits carry \
             no committer_name, so unlike canonical_author the committer side is not \
             mailmap-resolved — see the row's caveat). \
             Requires commit_parents table (schema v4) and merges ingested with \
             include_merges=true. Branch metrics are unreliable on squash/rebase workflows \
             (emits a warning when merge count < 3 and commit count > 50).",
            "See analyses/delivery_metrics.rs.",
        ),
        (
            "delivery-friction",
            "Where technical debt actively slows delivery: churn × review lead time × complexity",
            "friction_score = pr(revisions) × pr(median_lead_time_days) × pr(cognitive) × 100, in [0, 100]. All three percentile ranks must be elevated — one dominant signal alone does not light up the score. `wip_age_days` (days since last commit) is reported alongside so a high-friction file can be told apart as stale-but-still-touched vs hot-but-recently-active.",
            "See analyses/delivery_friction.rs.",
        ),
        (
            "function-xray",
            "Per-function change frequency for a single target file (Gall et al. ICSM 2003 HistoryFinder)",
            "Requires --target <repo-relative-path>. For each function/method alive at HEAD \
             in the target file, counts revisions where at least one hunk overlapped the \
             function's line span. Hunk-overlap attribution is more accurate than file-level \
             blame: it uses the span at change time. Pure deletions (new_lines=0) are attributed \
             to the function whose span contained the deleted anchor line. Sorted by change_freq \
             DESC.",
            "See analyses/function_xray.rs.",
        ),
        (
            "function-coupling",
            "Per-function-pair co-change frequency with Fisher significance for a single target file",
            "Requires --target <repo-relative-path>. For each pair of HEAD-alive functions in the \
             target file that co-changed (both touched in the same revision) in ≥2 revisions, \
             emits the pair with co-change count, per-function change counts, confidence \
             (co/min(a,b)), and two-tailed Fisher exact p-value. \
             Sorted by p-value ASC. Research: Adams et al. ICSM 2006.",
            "See analyses/function_coupling.rs.",
        ),
        (
            "function-hotspots",
            "Repo-wide function-level hotspot ranking (Gall et al. ICSM 2003 HistoryFinder + Tornhill 2018 the hotspot score this mirrors)",
            "percentile_rank(revs) × percentile_rank(cognitive) × (100 − cognitive_health) / 4, \
             range [0, 10] — the same formula `hotspots` uses, computed per HEAD-live \
             function/method instead of per file. `revs` = distinct revisions where a hunk \
             overlapped the function's HEAD span (`entities`), gated by --min-revs; the overlap \
             predicate is `function-xray`'s hunk↔span test, transliterated to SQL to run \
             repo-wide instead of per --target file. Approximate, not an exact replay: hunk line \
             numbers are historical while the span is the function's CURRENT (HEAD) location, so \
             attribution drifts as intervening commits shift line numbers — same caveat \
             `function-xray` documents. Answers \"hot now,\" not \"was hot historically\": \
             `entities.rev_introduced`/`rev_last_seen` are degenerate (always head_rev). Hunk \
             attribution keys on the literal path recorded at commit time, so a file's \
             pre-rename history is not attributed (same rename limitation as `function-xray`).",
            "See analyses/function_hotspots.rs + analyses/function_xray.rs (the overlap predicate).",
        ),
        (
            "cycle-origins",
            "Commit-level archaeology for dependency cycles",
            "For each dependency cycle at HEAD, binary-searches history (reading + resolving source at past revisions) to find the earliest commit where that cycle existed — the commit that closed the loop. Reports the forming commit's SHA + date per cycle. Assumes a cycle, once formed, stays formed; traces the largest cycles first to bound cost.",
            "See analyses/cycle_origins.rs.",
        ),
        (
            "dependency-cycles",
            "Tarjan 1972 SCC + Fontana et al. 2017 (Arcan) Cyclic Dependency smell",
            "Non-trivial strongly-connected components (size ≥ 2) of the structural import graph — files that import each other transitively. cycle_id groups a tangle; size is its member count. Accuracy follows the import resolver's language coverage.",
            "See analyses/dependency_cycles.rs + analyses/import_graph.rs.",
        ),
        (
            "modularity-violations",
            "Mo, Cai, Kazman, Xiao 2015 *Hotspot Patterns* (DV8) + Baldwin/MacCormack 2014 hidden structure",
            "Fisher-significant co-change pairs (from coupling) with NO directed dependency path between them (transitive reachability, either direction) — implicit cross-module dependencies. Ranked by coupling degree. Accuracy follows the import resolver's language coverage.",
            "See analyses/modularity_violations.rs.",
        ),
        (
            "unstable-interface",
            "Mo, Cai, Kazman, Xiao 2015 *Hotspot Patterns* (DV8)",
            "revisions × coupled_dependents, gated on fan_in ≥ 3 and revisions ≥ min_revs. A widely-imported file that changes often and co-changes with its dependents, so its instability propagates.",
            "See analyses/unstable_interface.rs.",
        ),
        (
            "crossing",
            "Mo, Cai, Kazman, Xiao 2015 *Hotspot Patterns* (DV8)",
            "A structural 'X' — fan_in ≥ 3 AND fan_out ≥ 3 — that co-changes with ≥1 importer AND ≥1 import, coupling upstream and downstream through itself. crossing_score = coupled_upstream + coupled_downstream.",
            "See analyses/crossing.rs.",
        ),
        (
            "bus-factor",
            "Filatov 2010 (commits mode) / Cury & Avelino SBES'24 (doe mode)",
            "Min number of authors whose departure would leave a module unmaintained. \
             Default mode (--knowledge-model commits): smallest set covering ≥80% of \
             module commits (Filatov 2010). DOE mode (--knowledge-model doe): greedy \
             truck-factor procedure — repeatedly remove the author expert on the most \
             remaining files until >50% of files have no expert (Cury & Avelino, \
             SBES'24 arXiv 2408.08733). DOE mode emits the same per-module row shape \
             with model='doe'.",
            "See analyses/bus_factor.rs.",
        ),
        (
            "stale-code",
            "code-age follow-up + Sonar 'trivial' threshold",
            "Files alive at HEAD AND untouched ≥12 months AND max(cognitive) ≤ 5. Intersection minimises false positives.",
            "See analyses/stale_code.rs.",
        ),
        (
            "pair-programming",
            "Co-Authored-By trailer convention (GitHub 2017)",
            "Counts commits where ≥1 `Co-Authored-By:` trailer present, by unique author pair.",
            "See analyses/pair_programming.rs.",
        ),
        (
            "lead-time",
            "DORA 2018 Accelerate",
            "Seconds between commit author-date and committer-date (proxy for in-flight review time). Schema_v3 carries only committer-date; schema_v4 will add author-date for real values.",
            "See analyses/lead_time.rs.",
        ),
        (
            "knowledge-islands",
            "Bird et al. 2011 risk-author",
            "Per-file bus-factor risk: primary author hasn't committed in `--departed-threshold-days` days AND no substantial other owners.",
            "See analyses/knowledge_islands.rs.",
        ),
        (
            "communities",
            "Leiden algorithm (Traag, Waltman, van Eck 2019)",
            "Modularity-optimising community detection on the Fisher-significant coupling graph. Surfaces Conway's-law clusters.",
            "See analyses/communities.rs.",
        ),
        (
            "centrality",
            "Newman 2010 §7",
            "Per-file degree, weighted-degree, and PageRank on the Fisher-significant coupling graph.",
            "See analyses/centrality.rs.",
        ),
        (
            "architecture-violations",
            "Layered architecture rules (Buschmann et al. 1996)",
            "Imports that cross a forbidden layer boundary per `.codelore-arch-rules.toml`. Empty rule set → empty output.",
            "See arch_rules/mod.rs + analyses/arch_violations.rs.",
        ),
        (
            "kamei-risk",
            "Kamei et al. 2013 (Just-In-Time Software Defect Prediction)",
            "Per-commit 14-feature vector (la, ld, nf, nd, ns, entropy, fix, ndev, age, nuc, exp, rexp, sexp, lt). Composite risk dimension explanation in the SPA's Delivery Risk Sparkline.",
            "See output/spa.rs::run_kamei_risk + facts/schema_v1.sql commits table.",
        ),
        (
            "revisions",
            "Nagappan & Ball 2005 — relative churn predicts defect density",
            "COUNT(rev) per file — distinct commits touching the path. Gated by --min-revs, ordered by n-revs descending.",
            "See analyses/revisions.rs.",
        ),
        (
            "authors",
            "Bird, Nagappan, Murphy, Devanbu & Zeller 2011 — \"Don't Touch My Code!\"",
            "Distinct canonical authors per file (n-authors = COUNT(DISTINCT author)), split into human vs bot via .mailmap + bot/AI attribution; n-revs = Σ per-author commit counts.",
            "See analyses/authors.rs.",
        ),
        (
            "ownership",
            "Mockus & Herbsleb 2002 + Hirschman 1980 (Herfindahl–Hirschman index)",
            "Fractal Value = 1 − Σᵢ (aᵢ / nc)², where aᵢ is author i's commit count on the file and nc the file's total commits. 0 = single owner, → 1 = fragmented. main-author = author with the most revisions.",
            "See analyses/ownership.rs.",
        ),
        (
            "code-age",
            "Tornhill 2015 — Your Code as a Crime Scene (software half-life)",
            "Whole calendar months between the file's latest commit (at-or-before the --age-time-now anchor, default now) and the anchor: 12·(yr−yr) + (mo−mo) − 1 if the anchor day-of-month is earlier than the last-commit day. Only files live at the anchor.",
            "See analyses/code_age.rs.",
        ),
        (
            "soc",
            "Tornhill 2018 — Software Design X-Rays (Sum of Coupling)",
            "Σ (commit_size − 1) over every commit the file appears in; a solo commit contributes 0. Per-file centrality across the change-coupling graph. Gated by --min-soc.",
            "See analyses/soc.rs.",
        ),
        (
            "abs-churn",
            "Nagappan & Ball 2005 — relative code churn predicts defect density",
            "Per calendar day across the repo: SUM(lines added), SUM(lines deleted), COUNT(commits). The absolute-churn time series.",
            "See analyses/churn.rs.",
        ),
    ];
    // Emit topic listings / dossiers through a locked stdout with propagating
    // `writeln!` so an early pipe close (`codelore explain | head`) reaches
    // `main`'s quiet-exit arm instead of panicking in the print macro.
    match &args.topic {
        None => {
            let mut out = std::io::stdout().lock();
            writeln!(out, "Supported topics:").context("write explain topics")?;
            for (name, _, _, _) in topics {
                writeln!(out, "  {name}").context("write explain topics")?;
            }
            writeln!(out, "\nUsage: codelore explain <topic>").context("write explain topics")?;
            Ok(())
        }
        Some(topic) => {
            let found = topics.iter().find(|(n, ..)| n.eq_ignore_ascii_case(topic));
            match found {
                Some((name, citation, formula, source)) => {
                    let mut out = std::io::stdout().lock();
                    writeln!(out, "# {name}\n").context("write explain topic")?;
                    writeln!(out, "**Citation**\n  {citation}\n").context("write explain topic")?;
                    writeln!(out, "**Formula**\n  {formula}\n").context("write explain topic")?;
                    writeln!(out, "**Source**\n  {source}\n").context("write explain topic")?;
                    writeln!(
                        out,
                        "**Foundations**\n  See docs/research-foundations.md for the full citation chain."
                    )
                    .context("write explain topic")?;
                    Ok(())
                }
                None => {
                    if let Some(repo_relative) = resolve_explain_file(&args.repo, topic) {
                        run_explain_file(args, &repo_relative)
                    } else {
                        let hint = crate::suggest::nearest(topic, topics.iter().map(|(n, ..)| *n))
                            .map(|s| format!(" (did you mean `{s}`?)"))
                            .unwrap_or_default();
                        Err(CodeLoreError::Analysis(format!(
                            "unknown topic `{topic}`{hint} — run `codelore explain` (no arg) to \
                             list supported topics, or pass an existing file path (with --repo) to \
                             print that file's evidence dossier"
                        ))
                        .into())
                    }
                }
            }
        }
    }
}

/// Resolve an `explain` argument that missed the topic table to a repo-relative
/// source-file path, or `None` when it names no existing file.
///
/// The argument is joined onto `--repo`; `Path::join` lets an absolute argument
/// replace the repo, so a repo-relative `src/x.rs`, a `--repo`-prefixed path,
/// and an absolute path to the same file all resolve to the same target. The
/// fact store keys on repo-relative, forward-slash paths, so the resolved path
/// is made relative to `--repo` and its separators are normalized to `/`.
fn resolve_explain_file(repo: &std::path::Path, arg: &str) -> Option<String> {
    let candidate = repo.join(arg);
    if !candidate.is_file() {
        return None;
    }
    let relative = match candidate.strip_prefix(repo) {
        Ok(stripped) => stripped.to_path_buf(),
        Err(_) => std::path::PathBuf::from(arg),
    };
    Some(relative.to_string_lossy().replace('\\', "/"))
}

/// Print the deterministic evidence dossier for a repo-relative source file,
/// and — with `--llm` — an advisory grounded narrative plus its citation-check
/// stamp.
///
/// This surface is strictly read-only: it opens (or ingests) the fact store and
/// assembles a fact sheet from the same analyses the CLI already exposes, never
/// touching an analysis row, a gate verdict, or a provenance manifest. Analysis
/// `min_revs` is forced to 1 so any single named file can be explained — the
/// default corpus gate would otherwise hide most files from their own dossier.
/// That 1-revision floor also applies to the dossier's hotspot, coupling, and
/// ownership sections, so their numbers can differ from a default `analyze` run
/// that gates low-revision files out.
///
/// Without `--llm`, when this file's own previously generated narrative exists
/// for a now-changed fact sheet, a one-line staleness note is printed. With
/// `--llm`, a missing LLM configuration is a hard error carrying a setup hint.
fn run_explain_file(args: &args::ExplainArgs, repo_relative: &str) -> Result<()> {
    use codelore_lib::cli_api::cache::default_cache_root;
    use codelore_lib::cli_api::enrichment::client::{LlmEnv, resolve_client};
    use codelore_lib::cli_api::enrichment::fact_sheet::FileFactSheet;
    use codelore_lib::cli_api::enrichment::prompt::Lens;
    use codelore_lib::cli_api::enrichment::{cache, engine};

    let cache_root = args.cache_dir.clone().unwrap_or_else(default_cache_root);
    let defect_calibration = codelore_lib::cli_api::quality_gates::resolve_defect_calibration(
        args.defect_calibration.clone(),
        &args.repo,
    )
    .context("resolve defect calibration")?;
    let opts = Options {
        repo_path: args.repo.clone(),
        min_revs: 1,
        defect_calibration,
        allow_foreign_calibration: args.allow_foreign_calibration,
        ..Options::default()
    };
    let repo = GixRepo::open(&args.repo)
        .with_context(|| format!("open git repo at {}", args.repo.display()))?;
    let db = FactsDb::open_or_ingest_with_cache_root(&opts, &repo, &cache_root)
        .context("open or ingest the fact store")?;
    // Witness the ingest: a real HEAD over an empty commit store is the
    // truncated-checkout signature (see `check.rs`). No date filter is
    // exposed here, so an empty store is unambiguously that case.
    let head_sha = repo.head_sha().context("get HEAD sha")?;
    db.ensure_ingest_witnessed(&head_sha)?;
    let sheet = FileFactSheet::build(&db, &repo, &opts, repo_relative)
        .with_context(|| format!("build the evidence dossier for {repo_relative}"))?;

    // Propagating writes over a locked stdout (see `run_explain_cmd`): the
    // dossier and any narrative are multi-line, so an early pipe close must
    // surface as a quiet exit, not a panic.
    {
        let mut out = std::io::stdout().lock();
        write!(out, "{}", sheet.to_human_text()).context("write evidence dossier")?;
    }

    if args.llm {
        let client = resolve_client(&LlmEnv::from_process_env()).context(
            "configure an LLM endpoint — set CODELORE_LLM_MODEL for a local OpenAI-compatible \
             runner (e.g. a model from `ollama list`), or ANTHROPIC_API_KEY for Anthropic; see \
             the CODELORE_LLM_* variables in the docs",
        )?;
        let canonical = sheet.to_canonical_text();
        let values = sheet.numeric_values();
        let result = engine::narrate(
            client.as_ref(),
            Lens::FileDiagnosis,
            repo_relative,
            engine::SheetFacts {
                text: &canonical,
                values: &values,
            },
            &cache_root,
            &args.repo,
            args.llm_refresh,
        )
        .context("generate the advisory narrative")?;
        let mut out = std::io::stdout().lock();
        writeln!(out, "\n{}", result.narrative).context("write narrative")?;
        writeln!(out, "{}", engine::stamp(&result)).context("write narrative stamp")?;
    } else if let Some(latest) = cache::latest_for_subject(&cache_root, &args.repo, repo_relative)
        && latest.fact_digest != sheet.digest()
    {
        let mut out = std::io::stdout().lock();
        writeln!(
            out,
            "note: cached narrative is stale — evidence changed; re-run with --llm"
        )
        .context("write staleness note")?;
    }

    Ok(())
}