heal-cli 0.4.0

Hook-driven Evaluation & Autonomous Loop — code-health harness CLI for AI coding agents
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
//! `Feature` — the unified interface that v0.3+ docs / coverage / lcov
//! readers will plug into and that v0.2's existing observers register
//! through.
//!
//! A Feature owns the lowering of one observer's typed report into
//! [`Vec<Finding>`] with severity + hotspot decoration applied. The
//! [`FeatureRegistry`] enumerates every builtin Feature and dispatches
//! the per-Feature `lower` step against a shared [`ObserverReports`]
//! plus [`Calibration`]. That replaces the inline per-metric branches
//! that used to live in `crate::observers::classify` — adding a new
//! metric (or, in v0.3, a new docs / coverage scanner) is now "implement
//! the trait + register".
//!
//! The runtime keeps `ObserverReports` as the inter-Feature glue
//! (Hotspot composition needs the typed Churn + Complexity reports, not
//! their Findings). User-facing output is always `Vec<Finding>`.

use std::path::{Path, PathBuf};

use crate::core::calibration::{Calibration, HotspotCalibration};
use crate::core::config::Config;
use crate::core::finding::{Finding, Location};
use crate::core::severity::Severity;
use crate::observer::code::hotspot::HotspotReport;
use crate::observer::docs::hotspot::DocHotspotReport;
use crate::observer::test::hotspot::TestHotspotReport;

/// Cheap, copyable metadata. Identifies the Feature in the registry
/// listing and tags the records the runtime writes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeatureMeta {
    pub name: &'static str,
    pub version: u32,
    pub kind: FeatureKind,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeatureKind {
    /// Reads project source / git history; this is the v0.2 default.
    Observer,
    /// Reads docs artifacts and source mtimes. Reserved for v0.3 — the
    /// storage and ingest path are TBD.
    DocsScanner,
    /// Reads lcov coverage files and emits Findings for low-coverage
    /// code. Reserved for v0.3.
    CoverageReader,
}

/// Feature family. Drives per-family `HotspotIndex` dispatch in
/// [`FeatureRegistry::lower_all`] so a `coverage_pct` Finding picks
/// up `hotspot=true` from the [`Family::Test`] index, a `doc_drift`
/// Finding from the [`Family::Docs`] index, and a `ccn` Finding from
/// the [`Family::Code`] index. Also surfaced to user-facing
/// `--feature` filters in the v0.4 status / metrics flow.
///
/// Variant order is the canonical render order (Code → Test → Docs)
/// — `BTreeMap<Family, _>` iteration relies on the derived `Ord`
/// matching that order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Family {
    Code,
    Test,
    Docs,
}

impl Family {
    /// Stable lower-case name used in error messages and JSON
    /// payloads. Matches the `--feature` flag value enum.
    #[must_use]
    pub fn name(self) -> &'static str {
        match self {
            Self::Code => "code",
            Self::Test => "test",
            Self::Docs => "docs",
        }
    }

    /// `heal status` family banner shown above each per-family
    /// section. Lives here so the renderer's `family_order` array
    /// can't drift out of sync with `Family` variants on a rename.
    #[must_use]
    pub fn banner(self) -> &'static str {
        match self {
            Self::Code => "═══ Code ═══",
            Self::Test => "═══ Test ═══",
            Self::Docs => "═══ Docs ═══",
        }
    }

    /// Title-cased family label used as the `[Code]` / `[Test]` /
    /// `[Docs]` prefix in `heal metrics` section titles. Same anti-
    /// drift rationale as [`Self::banner`] — keep the user-facing
    /// label spelling pinned to the variant.
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            Self::Code => "Code",
            Self::Test => "Test",
            Self::Docs => "Docs",
        }
    }

    /// Slash-command name of the family-specific drain skill —
    /// surfaced in the `Next: claude /heal-...-patch` hint at the
    /// foot of each family's status block. Same anti-drift rationale
    /// as [`Self::banner`].
    #[must_use]
    pub fn patch_skill(self) -> &'static str {
        match self {
            Self::Code => "/heal-code-patch",
            Self::Test => "/heal-test-patch",
            Self::Docs => "/heal-doc-patch",
        }
    }

    /// Is this family active for the project? Code is always on
    /// (the always-on observer set is foundational); Test and Docs
    /// follow their respective `[features.<f>]` master switches. The
    /// renderer skips disabled families silently; the `heal status`
    /// / `heal metrics` entry points exit non-zero when an explicit
    /// `--feature <disabled>` would otherwise produce empty output.
    #[must_use]
    pub fn is_enabled(self, cfg: &Config) -> bool {
        match self {
            Self::Code => true,
            Self::Test => cfg.features.test.enabled,
            Self::Docs => cfg.features.docs.enabled,
        }
    }

    /// Map a `Finding.metric` string to its [`Family`]. Canonical
    /// source of truth for the metric → family contract;
    /// `Feature::family()` overrides on the impl side and this
    /// method must agree, otherwise per-family `--feature` filtering
    /// and the renderer's family grouping diverge from the actual
    /// `HotspotIndex` dispatch. Unknown metric strings default to
    /// [`Family::Code`] — every observer registers a metric, so an
    /// unknown value is a bug and the conservative default keeps the
    /// finding visible under the always-on family rather than
    /// silently disappearing under a flag.
    #[must_use]
    pub fn for_metric(metric: &str) -> Self {
        match metric {
            "doc_freshness" | "doc_drift" | "doc_coverage" | "doc_link_health" | "orphan_pages"
            | "todo_density" | "doc_hotspot" => Self::Docs,
            "coverage_pct" | "skip_ratio" | "test_hotspot" => Self::Test,
            _ => Self::Code,
        }
    }
}

/// Per-file hotspot decoration index. Built once per `lower_all` pass
/// and threaded into each Feature so individual Findings can flip
/// `hotspot = true` without re-loading the hotspot report.
#[derive(Debug, Default)]
pub struct HotspotIndex {
    by_path: std::collections::HashMap<PathBuf, f64>,
    calibration: Option<HotspotCalibration>,
}

impl HotspotIndex {
    /// Build an index from a stream of `(path, score)` pairs +
    /// matching calibration. Duplicate keys keep the higher score —
    /// that's the doc-family case where one pair contributes its
    /// score under both `doc_path` and every `src_path`, and any
    /// other paired entry hitting the same src should not overwrite
    /// a stronger signal.
    fn from_entries(
        entries: impl IntoIterator<Item = (PathBuf, f64)>,
        calibration: Option<HotspotCalibration>,
    ) -> Self {
        let mut by_path: std::collections::HashMap<PathBuf, f64> = std::collections::HashMap::new();
        for (path, score) in entries {
            by_path
                .entry(path)
                .and_modify(|v| {
                    if score > *v {
                        *v = score;
                    }
                })
                .or_insert(score);
        }
        Self {
            by_path,
            calibration,
        }
    }

    /// Code-family Hotspot index (`commits × CCN_sum` per src file),
    /// calibrated against `cal.calibration.hotspot`.
    #[must_use]
    pub fn new(report: Option<&HotspotReport>, cal: &Calibration) -> Self {
        Self::from_entries(
            report
                .into_iter()
                .flat_map(|h| h.entries.iter().map(|e| (e.path.clone(), e.score))),
            cal.calibration.hotspot.clone(),
        )
    }

    /// Test-family Hotspot index (`commits × uncov_pct` per src file),
    /// calibrated against `cal.calibration.test_hotspot`.
    #[must_use]
    pub fn for_test(report: Option<&TestHotspotReport>, cal: &Calibration) -> Self {
        Self::from_entries(
            report
                .into_iter()
                .flat_map(|h| h.entries.iter().map(|e| (e.path.clone(), e.score))),
            cal.calibration.test_hotspot.clone(),
        )
    }

    /// Docs-family Hotspot index (`paired_src_churn × debt` per pair).
    /// The same score is registered under both the doc path and every
    /// paired src path so a `doc_freshness` Finding (primary = doc)
    /// and a `doc_drift` Finding whose `locations[]` touches a paired
    /// src both pick up the decoration. Calibrated against
    /// `cal.calibration.doc_hotspot`.
    #[must_use]
    pub fn for_doc(report: Option<&DocHotspotReport>, cal: &Calibration) -> Self {
        Self::from_entries(
            report.into_iter().flat_map(|h| {
                h.entries.iter().flat_map(|e| {
                    std::iter::once((e.doc_path.clone(), e.score))
                        .chain(e.src_paths.iter().map(|p| (p.clone(), e.score)))
                })
            }),
            cal.calibration.doc_hotspot.clone(),
        )
    }

    /// Whether `path`'s hotspot score crosses the calibration's `p90`.
    /// Returns `false` for files outside the index or when the project
    /// has no hotspot calibration yet.
    #[must_use]
    pub fn is_hot(&self, path: &Path) -> bool {
        match (&self.calibration, self.by_path.get(path)) {
            (Some(c), Some(score)) => c.flag(*score),
            _ => false,
        }
    }

    /// Convenience: a Finding's primary file or any of its
    /// `locations` is hot. `pub(crate)` because [`decorate`] is the
    /// only intended consumer; if v0.3 features need it, the contract
    /// can widen with intent.
    #[must_use]
    pub(crate) fn any_location_hot(&self, primary: &Location, locations: &[Location]) -> bool {
        self.is_hot(&primary.file) || locations.iter().any(|l| self.is_hot(&l.file))
    }
}

/// Apply Severity + hotspot decoration to a Finding in place. Used by
/// every Feature's lowering path; centralized here so the rule "hotspot
/// looks at primary file + every secondary location" only lives once.
/// `any_location_hot` short-circuits to `is_hot(primary)` when the
/// `locations` slice is empty, so single-site Findings flow through
/// the same path as multi-site ones without a special case.
#[must_use]
pub fn decorate(mut f: Finding, severity: Severity, hotspot: &HotspotIndex) -> Finding {
    f.severity = severity;
    f.hotspot = hotspot.any_location_hot(&f.location, &f.locations);
    f
}

/// The shared interface every metric (and v0.3 docs / coverage reader)
/// implements. The lifecycle the runtime drives is:
///
/// 1. `enabled(cfg)` — registry filter; disabled features never run.
/// 2. The runtime computes `ObserverReports` (cross-feature data —
///    Hotspot composition reads Churn + Complexity raw, etc.).
/// 3. `lower(reports, cfg, cal, hotspot)` — emit decorated Findings.
///
/// Two Features can share underlying observer state (CCN and
/// Cognitive both read `reports.complexity`), and that's intentional.
pub trait Feature: Send + Sync {
    fn meta(&self) -> FeatureMeta;
    fn enabled(&self, cfg: &Config) -> bool;
    /// Family this Feature belongs to. Drives per-family
    /// `HotspotIndex` dispatch in [`FeatureRegistry::lower_all`].
    /// Defaults to [`Family::Code`] so existing code-side Features
    /// keep working without a per-impl override; Test- and
    /// Docs-family Features override this method.
    fn family(&self) -> Family {
        Family::Code
    }
    /// Lower this Feature's slice of `reports` into Findings.
    /// Returns an empty Vec when the underlying observer didn't run
    /// (e.g. the Feature is enabled but its observer report is missing).
    fn lower(
        &self,
        reports: &crate::observers::ObserverReports,
        cfg: &Config,
        cal: &Calibration,
        hotspot: &HotspotIndex,
    ) -> Vec<Finding>;
}

/// Static registry of every builtin Feature. The order is the order
/// findings are emitted in `Vec<Finding>` — same-Severity tiebreakers
/// in the renderer fall back to it for determinism.
pub struct FeatureRegistry {
    features: Vec<Box<dyn Feature>>,
}

impl FeatureRegistry {
    /// All builtin Features. Order matters — same-Severity ties in the
    /// renderer fall back to it for stable output. Append new Features
    /// at the end to keep that contract.
    #[must_use]
    pub fn builtin() -> Self {
        use crate::observer::code::change_coupling::ChangeCouplingFeature;
        use crate::observer::code::complexity::ComplexityFeature;
        use crate::observer::code::duplication::DuplicationFeature;
        use crate::observer::code::hotspot::HotspotFeature;
        use crate::observer::code::lcom::LcomFeature;
        use crate::observer::docs::coverage::DocCoverageFeature;
        use crate::observer::docs::drift::DocDriftFeature;
        use crate::observer::docs::freshness::DocFreshnessFeature;
        use crate::observer::docs::hotspot::DocHotspotFeature;
        use crate::observer::docs::link_health::DocLinkHealthFeature;
        use crate::observer::docs::orphan_pages::OrphanPagesFeature;
        use crate::observer::docs::todo_density::TodoDensityFeature;
        use crate::observer::test::coverage::CoverageFeature;
        use crate::observer::test::hotspot::TestHotspotFeature;
        use crate::observer::test::skip_ratio::SkipRatioFeature;

        Self {
            features: vec![
                Box::new(ComplexityFeature),
                Box::new(DuplicationFeature),
                Box::new(ChangeCouplingFeature),
                Box::new(HotspotFeature),
                Box::new(LcomFeature),
                Box::new(DocFreshnessFeature),
                Box::new(DocDriftFeature),
                Box::new(DocCoverageFeature),
                Box::new(DocLinkHealthFeature),
                Box::new(OrphanPagesFeature),
                Box::new(TodoDensityFeature),
                Box::new(DocHotspotFeature),
                Box::new(CoverageFeature),
                Box::new(SkipRatioFeature),
                Box::new(TestHotspotFeature),
            ],
        }
    }

    /// Iterator of every registered Feature regardless of enabled state.
    pub fn iter(&self) -> impl Iterator<Item = &dyn Feature> {
        self.features.iter().map(std::convert::AsRef::as_ref)
    }

    /// Iterator filtered to features the supplied config enables.
    pub fn enabled<'a>(&'a self, cfg: &'a Config) -> impl Iterator<Item = &'a dyn Feature> + 'a {
        self.iter().filter(move |f| f.enabled(cfg))
    }

    /// Drive every enabled Feature's `lower` against shared inputs and
    /// concatenate the Findings. The replacement entry point for the
    /// pre-v0.2 `crate::observers::classify`.
    pub fn lower_all(
        &self,
        reports: &crate::observers::ObserverReports,
        cfg: &Config,
        cal: &Calibration,
    ) -> Vec<Finding> {
        let code_hotspot = HotspotIndex::new(reports.hotspot.as_ref(), cal);
        let test_hotspot = HotspotIndex::for_test(reports.test_hotspot.as_ref(), cal);
        let doc_hotspot = HotspotIndex::for_doc(reports.doc_hotspot.as_ref(), cal);
        let mut findings = Vec::new();
        for feature in self.enabled(cfg) {
            let idx = match feature.family() {
                Family::Code => &code_hotspot,
                Family::Test => &test_hotspot,
                Family::Docs => &doc_hotspot,
            };
            findings.extend(feature.lower(reports, cfg, cal, idx));
        }
        // When `[features.test]` is disabled, every finding keeps the
        // default `is_test_file = false` and the post-pass is skipped.
        if cfg.features.test.enabled {
            tag_test_findings(&mut findings, cfg);
        }
        findings
    }
}

/// Set [`Finding::is_test_file`] on every finding whose primary
/// `location.file` matches `cfg.features.test.test_paths` (gitignore
/// DSL). Glob compile errors fall back to the convention-based
/// [`crate::observer::shared::file_role::is_test_path`] heuristic so
/// a malformed user pattern doesn't suppress the flag entirely.
fn tag_test_findings(findings: &mut [Finding], cfg: &Config) {
    use crate::observer::shared::file_role::is_test_path;
    use crate::observer::shared::walk::ExcludeMatcher;

    let glob = if cfg.features.test.test_paths.is_empty() {
        None
    } else {
        ExcludeMatcher::compile(Path::new(""), &cfg.features.test.test_paths).ok()
    };
    for f in findings.iter_mut() {
        let path = &f.location.file;
        let hit = match glob.as_ref() {
            Some(m) => m.is_excluded(path, false),
            None => is_test_path(path),
        };
        if hit {
            f.is_test_file = true;
        }
    }
}

impl Default for FeatureRegistry {
    fn default() -> Self {
        Self::builtin()
    }
}

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

    #[test]
    fn builtin_registry_emits_one_feature_per_metric() {
        let r = FeatureRegistry::builtin();
        let names: Vec<&str> = r.iter().map(|f| f.meta().name).collect();
        // Order is the public emission contract — tests / renderer rely
        // on it for stable Finding ordering. Docs Features sit between
        // code and test so the v0.2 emission order for code metrics is
        // preserved; per-family hotspots (`doc_hotspot`, `test_hotspot`)
        // sit at the end of their own family blocks.
        assert_eq!(
            names,
            vec![
                "complexity",
                "duplication",
                "change_coupling",
                "hotspot",
                "lcom",
                "doc_freshness",
                "doc_drift",
                "doc_coverage",
                "doc_link_health",
                "orphan_pages",
                "todo_density",
                "doc_hotspot",
                "coverage_pct",
                "skip_ratio",
                "test_hotspot",
            ],
        );
    }

    #[test]
    fn every_code_feature_is_observer_kind() {
        // Code Features (the v0.2 set) are FeatureKind::Observer; docs
        // Features carry FeatureKind::DocsScanner; coverage_pct carries
        // FeatureKind::CoverageReader. The check is per-family rather
        // than per-Feature so adding a new code observer continues to
        // require Observer kind.
        for f in FeatureRegistry::builtin().iter() {
            let want_kind = match f.meta().name {
                "doc_freshness" | "doc_drift" | "doc_coverage" | "doc_link_health"
                | "orphan_pages" | "todo_density" | "doc_hotspot" => FeatureKind::DocsScanner,
                "coverage_pct" => FeatureKind::CoverageReader,
                _ => FeatureKind::Observer,
            };
            assert_eq!(
                f.meta().kind,
                want_kind,
                "unexpected kind for {}",
                f.meta().name,
            );
        }
    }

    #[test]
    fn family_assignment_per_feature_name() {
        for f in FeatureRegistry::builtin().iter() {
            let want_family = match f.meta().name {
                "doc_freshness" | "doc_drift" | "doc_coverage" | "doc_link_health"
                | "orphan_pages" | "todo_density" | "doc_hotspot" => Family::Docs,
                "coverage_pct" | "skip_ratio" | "test_hotspot" => Family::Test,
                _ => Family::Code,
            };
            assert_eq!(
                f.family(),
                want_family,
                "unexpected family for {}",
                f.meta().name,
            );
        }
    }
}