codelore-lib 0.27.3

CodeLore — Behavioral Code Analyzer library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
//! Own-repo defect calibration: the fix-commit oracle, the AG-SZZ linkage
//! engine, and the `defects.calib.json` artifact model.
//!
//! This module answers *does the health score actually predict where defects
//! land in THIS repository?* by mining a repo's own fix history (AG-SZZ),
//! validating code-health predictions against it, and — when the evidence
//! clears an honesty floor — tuning the eight smell weights. Everything here
//! is opt-in and vintage-stamped so default behavior stays byte-reproducible
//! without the feature.
//!
//! # Unit A — the fix-commit oracle
//!
//! [`DefectOracle`] is a dedicated, pure fix-commit classifier — deliberately
//! **separate from** the kamei `fix` regex (`kamei::enrich_fix`), which stays
//! untouched: it is a JIT-SDP feature input whose broad `issue|error|patch`
//! alternation is a documented SZZ precision trap. This oracle uses a
//! narrower, word-boundary-anchored vocabulary intended specifically for
//! linking fixes to the defects they resolve.
//!
//! # Unit B — the AG-SZZ linkage engine
//!
//! [`szz`] traces each fix commit's deleted pre-image lines back to the
//! commit that last introduced them, behind a pluggable
//! [`szz::LineOriginSource`] seam — the roadmap's "pluggable SZZ". This
//! module has no production git-subprocess implementation; that lives
//! CLI-side, shelling `git blame --porcelain` and parsing it with
//! [`szz::parse_blame_porcelain`].
//!
//! # Unit C — the historical band scan + validation report
//!
//! [`validate::band_history`] recomputes code-health bands at ≤12 evenly
//! spaced historical revisions (the same at-rev machinery `health_trend`
//! uses, but with full path coverage — no top-50 cap). [`validate::validate`]
//! matches each defect-introducing commit to the nearest band sample
//! at-or-before its date and reports the headline band table plus AUC /
//! precision@k of HEAD's `structural_risk` against the defect-implicated
//! file labels.
//!
//! # Unit D — constrained weight tuning
//!
//! [`validate::tune_weights`] runs a deterministic coordinate-descent search
//! over the eight smell weights and only adopts a tuned set when the
//! evidence clears the honesty floor and the acceptance margin — see that
//! function's rustdoc for the full design decision (biomarker-intensity
//! capture + the Rust-side risk-scoring formula it re-scores candidates
//! with).
//!
//! # Unit E — the artifact
//!
//! [`DefectArtifact`] is a versioned, compact JSON container (mirroring
//! `calibration::CalibrationArtifact`'s serde style and write idiom). It
//! records the oracle configuration used, mining stats, validation metrics,
//! the (possibly tuned) smell weights, and the tuning decision. [`save`] /
//! [`load`] round-trip it; [`check_repo_identity`] guards against applying an
//! artifact mined from a different repository.

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

use regex::Regex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::{CodeLoreError, Result};

pub mod szz;
pub mod validate;

/// Artifact schema version. A [`load`]ed artifact whose `format_version`
/// differs is rejected with a hard error — an explicitly passed
/// `--defect-calibration` file that cannot be used is a configuration
/// mistake, not a degradable state (mirrors
/// `calibration::CALIBRATION_FORMAT_VERSION`).
pub const DEFECT_FORMAT_VERSION: u32 = 2;

// ─── Unit A: fix-commit oracle ───────────────────────────────────────────────

/// Configuration for [`DefectOracle`]. `extra_patterns` are additional regexes
/// OR'd in alongside the built-in classifiers — for teams with tracker-id
/// conventions (e.g. `"JIRA-\\d+"`). Defaults to no extra patterns.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OracleConfig {
    pub extra_patterns: Vec<String>,
}

/// A pure fix-commit classifier over a commit message.
///
/// A commit is a fix iff its message matches, case-insensitively, either:
/// - a conventional-commit prefix `fix:` / `fix(scope):` anchored to the
///   start of the message, or
/// - a defect-vocabulary term at word boundaries: `bug`, `fix`/`fixes`/
///   `fixed`, `defect`, `regression`, `hotfix` are matched as whole words
///   (`\b…\b` on both sides — `fixture`, `hotfixture`, `defective`, `affix`
///   never count), while `bugfix` alone is leading-boundary-only so its
///   plural compound `bugfixes` still counts,
///
/// AND the caller-supplied `is_merge` flag is false, AND the message does not
/// begin with the literal git revert prefix `Revert "`.
///
/// `extra_patterns` from [`OracleConfig`] are additional regexes OR'd into the
/// classification, compiled once here in [`DefectOracle::new`].
///
/// Deliberately separate from `kamei::enrich_fix`'s `\b(bug|fix|fixes|fixed|
/// defect|patch|hotfix|issue|error)\b` — that regex is a JIT-SDP feature input
/// left untouched; its broader alternation (`patch`, `issue`, `error`) is a
/// documented SZZ precision trap this oracle deliberately narrows.
#[derive(Debug, Clone)]
pub struct DefectOracle {
    conventional: Regex,
    word_boundary: Regex,
    extra: Vec<Regex>,
}

/// Literal prefix marking a git-generated revert commit message
/// (`Revert "<original subject>"`). Checked case-sensitively — this is the
/// exact prefix `git revert` itself produces.
const REVERT_PREFIX: &str = "Revert \"";

impl DefectOracle {
    /// Compile the built-in classifiers plus `cfg.extra_patterns`.
    ///
    /// # Errors
    ///
    /// [`CodeLoreError::InvalidOptions`] when an entry in `extra_patterns` is
    /// not a valid regex — a configuration mistake, not a degradable state.
    pub fn new(cfg: &OracleConfig) -> Result<Self> {
        let conventional = Regex::new(r"(?i)^fix(\([^)]*\))?:").map_err(|e| {
            CodeLoreError::Analysis(format!("built-in oracle conventional pattern: {e}"))
        })?;
        // `bugfix` is leading-boundary-only so `bugfixes` counts; every other
        // term is a whole word (`\b…\b`) so `fixture`/`hotfixture`/`defective`
        // — common non-defect vocabulary, verified against this repository's
        // own commit history — never classify as fixes.
        let word_boundary =
            Regex::new(r"(?i)(?:\bbugfix|\b(?:bug|fix(?:es|ed)?|defect|regression|hotfix)\b)")
                .map_err(|e| {
                    CodeLoreError::Analysis(format!("built-in oracle word-boundary pattern: {e}"))
                })?;
        let mut extra = Vec::with_capacity(cfg.extra_patterns.len());
        for pattern in &cfg.extra_patterns {
            let re = Regex::new(pattern).map_err(|e| {
                CodeLoreError::InvalidOptions(format!(
                    "defect oracle extra pattern {pattern:?}: {e}"
                ))
            })?;
            extra.push(re);
        }
        Ok(Self {
            conventional,
            word_boundary,
            extra,
        })
    }

    /// Classify `message` as a fix commit. `is_merge` is caller-supplied
    /// (this module has no notion of the commit graph) and, when true,
    /// unconditionally yields `false` regardless of message content.
    #[must_use]
    pub fn is_fix(&self, message: &str, is_merge: bool) -> bool {
        if is_merge || message.starts_with(REVERT_PREFIX) {
            return false;
        }
        self.conventional.is_match(message)
            || self.word_boundary.is_match(message)
            || self.extra.iter().any(|re| re.is_match(message))
    }
}

// ─── Unit E: artifact model ───────────────────────────────────────────────────

/// Mining-phase tallies recorded on every built artifact, whether or not any
/// fixes were found (an empty-linkage artifact is never an error — see the
/// spec's Error handling section).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MiningStats {
    /// Commits the oracle classified as fixes.
    pub fixes_found: u32,
    /// `(defect_rev, fix_rev, path)` links the AG-SZZ engine emitted.
    pub links_found: u32,
    /// Distinct `(fix_rev, path)` pairs sent through `git blame`.
    pub files_blamed: u32,
    /// Deleted pre-image lines examined across all fix hunks.
    pub lines_considered: u32,
    /// Of those, lines the AG filter dropped as cosmetic (blank / comment-only).
    pub lines_dropped_cosmetic: u32,
    /// Blame or blob-read failures — skipped, never fatal.
    pub blame_failures: u32,
    /// Fix hunks that were pure additions (no deleted lines, so no candidates).
    pub pure_addition_fixes: u32,
    /// Fix commits the tangled-commit guard excluded from linkage entirely
    /// (touched more than [`szz::TANGLED_MAX_FILES`] files or changed more
    /// than [`szz::TANGLED_MAX_CHURN`] lines).
    ///
    /// `#[serde(skip)]`: a transient mining tally surfaced only in the command
    /// output, never written to the artifact — so `DEFECT_FORMAT_VERSION` does
    /// not move for a methodology disclosure. Always reloads as `0`.
    #[serde(skip)]
    pub fixes_excluded_tangled: u32,
    /// Whole-file-deletion blame targets the ghost guard skipped: a file
    /// removed wholesale contributes only removed lines, which cannot embody
    /// an in-place fix, so blaming them would over-attribute the "defect" to
    /// every past author. Transient, never serialized (see
    /// `fixes_excluded_tangled`).
    #[serde(skip)]
    pub ghost_files_skipped: u32,
}

/// Validation-report metrics: does HEAD's structural risk predict where the
/// mined defects landed? Presentation follows the project's honesty framing —
/// association, not causation; every number carries its `n`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ValidationMetrics {
    /// `(band, defect_changes, share)` — share of defect-introducing changes
    /// that landed in files at each code-health band at the time.
    pub band_table: Vec<(String, u32, f64)>,
    /// AUC of HEAD's `structural_risk` against the defect-implicated-file
    /// labels, using the default (untuned) weights. `None` when either class
    /// (implicated / not) is empty.
    pub auc_default: Option<f64>,
    /// Precision among the 10 highest-risk files. `None` when fewer than 10
    /// files have health data.
    pub precision_at_10: Option<f64>,
    /// Precision among the files banded red at HEAD. `None` when zero files
    /// are red.
    pub precision_at_red: Option<f64>,
    /// Distinct files touched by at least one defect-introducing commit.
    pub implicated_files: u32,
    /// Distinct defect-introducing commits (the SZZ links' defect side,
    /// deduplicated).
    pub linked_defects: u32,
    /// RFC 3339 dates of the historical band-scan samples used to look up
    /// "health at the time" for each defect-introducing commit.
    pub sample_dates: Vec<String>,
    /// Defect-introducing commits excluded because no health sample existed
    /// at or before their date (and none is the earliest sample either).
    pub excluded_no_data: u32,
}

/// The tuning decision recorded on every artifact — which branch of the
/// honesty floor / acceptance test fired, and both AUCs so a reader can judge
/// the outcome even when defaults were kept.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "outcome")]
pub enum TuningDecision {
    /// The tuned weights were adopted: the validation-split AUC improvement
    /// cleared the acceptance margin over the default weights.
    Applied {
        /// AUC on the training split, for the chosen (tuned) weights.
        auc_train: f64,
        /// AUC on the validation split, for the *default* weights.
        auc_validation_default: f64,
        /// AUC on the validation split, for the *tuned* weights.
        auc_validation_tuned: f64,
    },
    /// Defaults were kept. `reason` names which branch fired: an honesty-floor
    /// sample-size guard (too few linked defect-changes / implicated files) or an
    /// unmet acceptance margin.
    DefaultsKept {
        reason: String,
        /// `Some` when the margin-unmet branch computed both AUCs; `None`
        /// when an earlier sample-size floor short-circuited before any AUC
        /// existed.
        auc_validation_default: Option<f64>,
        /// See `auc_validation_default`.
        auc_validation_tuned: Option<f64>,
    },
}

/// A versioned own-repo defect-calibration artifact (`defects.calib.json`).
///
/// Mirrors `calibration::CalibrationArtifact`'s serde style: a compact
/// (non-pretty) JSON container, loaded and validated by [`load`], written by
/// [`save`]. Applying an artifact mined from a different repository is a hard
/// error unless explicitly overridden — see [`check_repo_identity`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DefectArtifact {
    pub format_version: u32,
    /// SHA-256 hex (64 chars) identifying *which repository* this artifact was
    /// mined from, derived from the repo's root commit SHA — see
    /// [`repo_identity`]. Distinct from `head_at_mining`: this identifies
    /// *which repository*, not *which commit*.
    pub repo_identity: String,
    /// HEAD SHA at mining time.
    pub head_at_mining: String,
    /// Human-readable vintage, e.g. `"defects-2026-07-15"`.
    pub vintage: String,
    /// RFC 3339 build timestamp (caller-injected, like
    /// `calibration::build_from_observations`'s `generated_at`, so builds are
    /// byte-reproducible given identical inputs and timestamp).
    pub generated_at: String,
    /// The oracle configuration used to mine this artifact.
    pub oracle: OracleConfig,
    pub mining: MiningStats,
    pub validation: ValidationMetrics,
    /// The eight smell weights (tuned or left at their defaults), in
    /// `code_health::SMELL_WEIGHTS` order.
    pub weights: Vec<(String, f64)>,
    pub tuning: TuningDecision,
}

/// Serialize `artifact` as compact JSON and write it to `path`, creating
/// parent directories as needed.
///
/// Mirrors the `calibrate` command's write idiom exactly: `serde_json::to_vec`
/// (compact, not pretty) so byte-determinism tests over identical inputs are
/// meaningful, not an artifact of formatting.
///
/// # Errors
///
/// [`CodeLoreError::Analysis`] if serialization fails (should not happen for
/// a well-formed `DefectArtifact`); [`CodeLoreError::Io`] if the parent
/// directory cannot be created or the file cannot be written.
pub fn save(artifact: &DefectArtifact, path: &Path) -> Result<()> {
    let bytes = serde_json::to_vec(artifact).map_err(|e| {
        CodeLoreError::Analysis(format!("serialize defect-calibration artifact: {e}"))
    })?;
    if let Some(parent) = path.parent()
        && !parent.as_os_str().is_empty()
    {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(path, &bytes)?;
    Ok(())
}

/// Read and validate a defect-calibration artifact from `path`.
///
/// # Errors
///
/// [`CodeLoreError::RepoIo`] (read-side input, exit 3) when the file cannot
/// be read; [`CodeLoreError::Analysis`] (exit 4) when the JSON is malformed
/// or `format_version` does not match [`DEFECT_FORMAT_VERSION`] — the error
/// message names both versions. An explicitly passed `--defect-calibration`
/// file that cannot be used is a configuration mistake, not a degradable
/// state, so both failure modes are hard errors.
pub fn load(path: &Path) -> Result<DefectArtifact> {
    let bytes = std::fs::read(path).map_err(|e| {
        CodeLoreError::RepoIo(std::io::Error::new(
            e.kind(),
            format!("read defect-calibration artifact {}: {e}", path.display()),
        ))
    })?;
    let artifact: DefectArtifact = serde_json::from_slice(&bytes).map_err(|e| {
        CodeLoreError::Analysis(format!(
            "parse defect-calibration artifact {}: {e}",
            path.display()
        ))
    })?;
    if artifact.format_version != DEFECT_FORMAT_VERSION {
        return Err(CodeLoreError::Analysis(format!(
            "unknown defect-calibration format_version {} (this build supports {DEFECT_FORMAT_VERSION}): {}",
            artifact.format_version,
            path.display()
        )));
    }
    Ok(artifact)
}

/// `fs::canonicalize` wrapper that falls back to the raw path (with a debug
/// log) on failure — mirrors `cache::canonicalize_with_fallback_log` so a
/// repo's identity fingerprint is derived the same way the cache key is.
fn canonicalize_repo_path(repo_path: &Path) -> PathBuf {
    match std::fs::canonicalize(repo_path) {
        Ok(canonical) => canonical,
        Err(e) => {
            tracing::debug!(
                "defect_calibration::repo_identity: canonicalize fallback for {} ({e}); using raw path",
                repo_path.display()
            );
            repo_path.to_path_buf()
        }
    }
}

/// Derive a repository's identity from its root (parentless) commit SHA(s).
///
/// The root commit is git's stable repo fingerprint: unchanged by moving or
/// re-cloning the repository (the commit object is content-addressed), distinct
/// across unrelated repositories, and shared across forks — so a fork
/// legitimately reuses its parent's calibration artifact. History is walked
/// from `HEAD` and every parentless commit collected; a lone root hashes on its
/// own, while multiple roots (grafted or merged histories) are sorted and
/// NUL-joined before hashing so the result is order-independent.
///
/// Returns the reason for falling back (as `Err`) — rather than a bare `None` —
/// so [`repo_identity`] can name it in its warning: `repo_path` is not a git
/// repository, `HEAD` cannot be resolved, the walk fails, or no root commit is
/// reachable (e.g. a shallow clone whose root is absent).
fn root_commit_identity(repo_path: &Path) -> std::result::Result<String, String> {
    let repo = gix::open(repo_path).map_err(|e| format!("not a git repository ({e})"))?;
    let head = repo
        .head_id()
        .map_err(|e| format!("cannot resolve HEAD ({e})"))?;
    let walk = repo
        .rev_walk([head])
        .all()
        .map_err(|e| format!("history walk failed ({e})"))?;
    // `Info::parent_ids` is populated eagerly for every yielded commit, so a
    // root is exactly a commit with no parents — no second object lookup
    // needed to re-parse the commit.
    let mut roots: Vec<String> = Vec::new();
    for info in walk {
        let info = info.map_err(|e| format!("history walk failed ({e})"))?;
        if info.parent_ids.is_empty() {
            roots.push(info.id.to_hex().to_string());
        }
    }
    if roots.is_empty() {
        return Err("no root commit reachable (shallow clone?)".to_string());
    }
    roots.sort_unstable();
    let mut hasher = Sha256::new();
    hasher.update(roots.join("\0").as_bytes());
    Ok(hex::encode(hasher.finalize()))
}

/// SHA-256 hex (all 64 chars) identifying the repository at `repo_path` — used
/// both to stamp a freshly-mined artifact's `repo_identity` and, via
/// [`check_repo_identity`], to verify one before applying it.
///
/// The identity derives from the repo's root commit SHA(s) (see
/// [`root_commit_identity`]) so it survives moves and re-clones and does not
/// collide across unrelated repos sharing a path. When the root commit cannot
/// be resolved (non-git or shallow path), it falls back to the SHA-256 of the
/// canonicalized path — the full-length counterpart to `cache::repo_hash_short`'s
/// 8-char truncation — keeping the function infallible.
#[must_use]
pub fn repo_identity(repo_path: &Path) -> String {
    match root_commit_identity(repo_path) {
        Ok(identity) => identity,
        Err(reason) => {
            tracing::warn!(
                "defect_calibration::repo_identity: could not resolve root commit for {} ({reason}); falling back to path-based identity",
                repo_path.display()
            );
            let canonical = canonicalize_repo_path(repo_path);
            let mut hasher = Sha256::new();
            hasher.update(canonical.to_string_lossy().as_bytes());
            hex::encode(hasher.finalize())
        }
    }
}

/// Guard against applying an artifact mined from a different repository.
///
/// Compares `art.repo_identity` against [`repo_identity`] of `repo_path`.
/// `allow_foreign` is the `--allow-foreign-calibration` escape hatch for
/// forks: when true, the check is skipped unconditionally.
///
/// # Errors
///
/// [`CodeLoreError::Analysis`] when the identities differ and
/// `allow_foreign` is false. Never errors when `allow_foreign` is true.
pub fn check_repo_identity(
    art: &DefectArtifact,
    repo_path: &Path,
    allow_foreign: bool,
) -> Result<()> {
    if allow_foreign {
        return Ok(());
    }
    let actual = repo_identity(repo_path);
    if actual == art.repo_identity {
        return Ok(());
    }
    Err(CodeLoreError::Analysis(format!(
        "defect-calibration artifact was mined from a different repository (artifact identity {}, this repo is {actual}); pass --allow-foreign-calibration to apply it anyway",
        art.repo_identity
    )))
}

/// The smell-weight table and artifact vintage resolved by [`active_weights`].
pub type WeightsAndVintage = (Vec<(String, f64)>, String);

/// Resolve the smell weights active for `opts`: `Some((weights, vintage))`
/// when a `--defect-calibration` artifact is configured and passes both the
/// repo-identity guard and shape validation; `None` when no artifact is
/// configured.
///
/// The returned weights are the artifact's `weights` field verbatim — for a
/// `DefaultsKept` artifact those ARE the defaults, so substituting them is
/// inert by construction while the vintage stamp still records that the
/// artifact was consulted.
///
/// Shape validation pins the weights to exactly the built-in smell names, in
/// the built-in order, with finite non-negative values. Anything else is a
/// corrupted or incompatible artifact — and rejecting it here is also what
/// keeps interpolating the names into the code-health SQL injection-free.
///
/// # Errors
///
/// Propagates [`load`] failures, the [`check_repo_identity`] foreign-repo
/// guard (see `Options::allow_foreign_calibration`), and shape-validation
/// failures, all as hard errors: an explicitly configured artifact that
/// cannot be applied is a configuration mistake, not a degradable state.
pub fn active_weights(opts: &crate::Options) -> Result<Option<WeightsAndVintage>> {
    let Some(path) = &opts.defect_calibration else {
        return Ok(None);
    };
    let art = load(path)?;
    check_repo_identity(&art, &opts.repo_path, opts.allow_foreign_calibration)?;
    let expected = crate::analyses::code_health::SMELL_WEIGHTS;
    if art.weights.len() != expected.len()
        || art
            .weights
            .iter()
            .zip(expected)
            .any(|((name, w), &(exp, _))| name != exp || !w.is_finite() || *w < 0.0)
    {
        return Err(CodeLoreError::Analysis(format!(
            "defect-calibration artifact {} has malformed weights: expected the {} built-in smells in canonical order with finite non-negative values",
            path.display(),
            expected.len()
        )));
    }
    Ok(Some((art.weights, art.vintage)))
}

/// Vintage string of the defect-calibration artifact active for `opts`. A
/// thin wrapper over [`active_weights`] — the one place resolution, the
/// identity guard, and shape validation live — so the provenance stamp and
/// the weight substitution never drift apart (mirrors
/// `calibration::active_vintage`).
///
/// # Errors
///
/// Propagates [`active_weights`] failures.
pub fn active_vintage(opts: &crate::Options) -> Result<Option<String>> {
    Ok(active_weights(opts)?.map(|(_, vintage)| vintage))
}

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

    fn oracle(extra_patterns: &[&str]) -> DefectOracle {
        let cfg = OracleConfig {
            extra_patterns: extra_patterns.iter().map(|s| (*s).to_string()).collect(),
        };
        DefectOracle::new(&cfg).expect("valid oracle config compiles")
    }

    // ─── Unit A oracle table (verbatim from the design spec) ────────────────

    #[test]
    fn conventional_prefix_matches_case_insensitively() {
        let o = oracle(&[]);
        assert!(o.is_fix("fix: null deref", false));
        assert!(o.is_fix("Fix(parser): does the thing", false));
    }

    #[test]
    fn word_boundary_terms_match() {
        let o = oracle(&[]);
        assert!(o.is_fix("bugfix for #12", false));
        assert!(o.is_fix("regression in DSM", false));
    }

    #[test]
    fn bugfix_is_leading_only_so_its_plural_compound_still_matches() {
        // `bugfix` is the one leading-boundary-only term, so its plural
        // compound "bugfixes" still counts; every other term requires a
        // trailing boundary too.
        let o = oracle(&[]);
        assert!(o.is_fix("prefix bugfixes", false));
    }

    #[test]
    fn fixture_vocabulary_never_classifies_as_a_fix() {
        // "fixture"/"fixtures" are everyday testing vocabulary — this
        // repository's own history carries test/ci commits mentioning them —
        // and the whole-word boundaries keep them out of the defect set.
        let o = oracle(&[]);
        assert!(!o.is_fix("test(fixtures): biomarker repo exercises nesting", false));
        assert!(!o.is_fix("Shared test fixtures as checked-in git bundles", false));
        assert!(!o.is_fix("hotfixture deploy", false));
        assert!(!o.is_fix("defective by design", false));
    }

    #[test]
    fn mid_word_occurrence_without_a_leading_boundary_does_not_match() {
        // "affix" contains the literal substring "fix", but it starts
        // mid-word (preceded by another word character), so the leading
        // boundary check correctly excludes it.
        let o = oracle(&[]);
        assert!(!o.is_fix("affix labels", false));
    }

    #[test]
    fn patch_is_not_in_the_strict_vocabulary() {
        // Unlike the kamei fix regex, "patch" is deliberately excluded here.
        let o = oracle(&[]);
        assert!(!o.is_fix("patch bump", false));
    }

    #[test]
    fn revert_prefix_excludes_regardless_of_body_content() {
        let o = oracle(&[]);
        assert!(!o.is_fix("Revert \"fix: x\"", false));
    }

    #[test]
    fn merge_flag_excludes_regardless_of_message() {
        let o = oracle(&[]);
        assert!(!o.is_fix("fix: this would otherwise match", true));
    }

    #[test]
    fn extra_pattern_is_ored_in() {
        let o = oracle(&["JIRA-\\d+"]);
        assert!(o.is_fix("JIRA-77 crash", false));
        // Extra patterns don't disable the built-ins.
        assert!(o.is_fix("fix: still works", false));
        // And a message matching neither built-in nor extra stays false.
        assert!(!o.is_fix("refactor: tidy imports", false));
    }

    #[test]
    fn invalid_extra_pattern_is_a_typed_configuration_error() {
        let cfg = OracleConfig {
            extra_patterns: vec!["(unclosed".to_string()],
        };
        let err = DefectOracle::new(&cfg).expect_err("invalid regex must fail to compile");
        assert!(
            matches!(err, CodeLoreError::InvalidOptions(_)),
            "invalid extra pattern must surface as InvalidOptions, got: {err:?}"
        );
    }

    // ─── artifact: identity helper ───────────────────────────────────────────

    #[test]
    fn repo_identity_is_deterministic_and_full_length_hex() {
        let dir = std::env::temp_dir();
        let a = repo_identity(&dir);
        let b = repo_identity(&dir);
        assert_eq!(
            a, b,
            "repo_identity must be deterministic for the same path"
        );
        assert_eq!(a.len(), 64, "expected full 64-char sha256 hex, got {a:?}");
        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn repo_identity_differs_for_different_paths() {
        let a = repo_identity(Path::new("/tmp"));
        let b = repo_identity(Path::new("/var"));
        assert_ne!(a, b, "distinct paths must hash to distinct identities");
    }

    // ─── artifact: identity from the root commit SHA ─────────────────────────

    /// Run a `git` subcommand in `dir`, asserting success.
    #[cfg(feature = "test-support")]
    fn run_git(dir: &Path, args: &[&str]) {
        let ok = std::process::Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(args)
            .status()
            .expect("spawn git")
            .success();
        assert!(ok, "git {args:?} failed");
    }

    /// Initialise a git repo at `dir` with a single commit — fixed author,
    /// committer and date so the root commit SHA is deterministic — holding one
    /// file whose body is `content`.
    #[cfg(feature = "test-support")]
    fn init_repo_with_commit(dir: &Path, message: &str, content: &str) {
        run_git(dir, &["init", "-b", "main", "--quiet"]);
        run_git(dir, &["config", "user.email", "fixture@example.com"]);
        run_git(dir, &["config", "user.name", "Fixture"]);
        std::fs::write(dir.join("file.txt"), content).expect("write fixture file");
        run_git(dir, &["add", "."]);
        let date = "2026-01-01T00:00:00Z";
        let ok = std::process::Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(["commit", "--quiet", "-m", message])
            .env("GIT_AUTHOR_DATE", date)
            .env("GIT_COMMITTER_DATE", date)
            .status()
            .expect("spawn git commit")
            .success();
        assert!(ok, "git commit failed");
    }

    /// The root (parentless) commit SHA of `dir`, via the git binary — the
    /// independent oracle the gix-derived identity is checked against.
    #[cfg(feature = "test-support")]
    fn root_sha_via_git(dir: &Path) -> String {
        let out = std::process::Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(["rev-list", "--max-parents=0", "HEAD"])
            .output()
            .expect("spawn git rev-list");
        assert!(out.status.success(), "git rev-list failed");
        String::from_utf8(out.stdout)
            .expect("utf8 sha")
            .trim()
            .to_string()
    }

    /// The pre-fix path-only hash of `dir`, recomputed inline so tests can
    /// prove the identity no longer equals it for a real repo.
    #[cfg(feature = "test-support")]
    fn path_only_identity(dir: &Path) -> String {
        let canonical = canonicalize_repo_path(dir);
        let mut hasher = Sha256::new();
        hasher.update(canonical.to_string_lossy().as_bytes());
        hex::encode(hasher.finalize())
    }

    /// Moving or re-cloning the SAME repository to a new path must not change
    /// its identity: a full clone copies the root commit object verbatim, so
    /// its SHA — and the derived identity — is path-independent. (On the
    /// pre-fix path-only code the two paths hash differently.)
    #[test]
    #[cfg(feature = "test-support")]
    fn repo_identity_is_stable_across_reclone_to_a_new_path() {
        let origin = tempfile::tempdir().expect("origin tempdir");
        init_repo_with_commit(origin.path(), "root commit", "alpha\n");
        let id_origin = repo_identity(origin.path());

        let clone_parent = tempfile::tempdir().expect("clone tempdir");
        let clone_path = clone_parent.path().join("reclone");
        let ok = std::process::Command::new("git")
            .args(["clone", "--quiet"])
            .arg(origin.path())
            .arg(&clone_path)
            .status()
            .expect("spawn git clone")
            .success();
        assert!(ok, "git clone failed");
        let id_clone = repo_identity(&clone_path);

        assert_eq!(
            id_origin, id_clone,
            "a moved / re-cloned repo must keep its identity (root commit SHA is path-independent)"
        );
    }

    /// Two independently initialised repos have distinct root commits (distinct
    /// content and message → distinct root SHAs) and so must have distinct
    /// identities — the foreign-repo guard the identity exists to enforce.
    #[test]
    #[cfg(feature = "test-support")]
    fn repo_identity_differs_for_independently_initialised_repos() {
        let a = tempfile::tempdir().expect("tempdir a");
        let b = tempfile::tempdir().expect("tempdir b");
        init_repo_with_commit(a.path(), "root of repo a", "content-a\n");
        init_repo_with_commit(b.path(), "root of repo b", "content-b\n");
        assert_ne!(
            repo_identity(a.path()),
            repo_identity(b.path()),
            "independent repos with distinct root commits must have distinct identities"
        );
    }

    /// For a real git repo the identity must derive from the root commit SHA,
    /// not the path: it does not equal the path-only hash (proving the git
    /// branch ran, not the fallback), and it equals `sha256(root_commit_sha)`.
    #[test]
    #[cfg(feature = "test-support")]
    fn repo_identity_uses_root_commit_sha_not_path_for_a_git_repo() {
        let dir = tempfile::tempdir().expect("tempdir");
        init_repo_with_commit(dir.path(), "sole commit", "body\n");

        let identity = repo_identity(dir.path());
        assert_ne!(
            identity,
            path_only_identity(dir.path()),
            "for a real git repo the identity must derive from the root commit, not the path"
        );

        let mut hasher = Sha256::new();
        hasher.update(root_sha_via_git(dir.path()).as_bytes());
        let expected = hex::encode(hasher.finalize());
        assert_eq!(
            identity, expected,
            "identity must be sha256 of the root commit SHA"
        );
    }

    /// Add one commit on top of `HEAD` in an existing repo, advancing `HEAD`
    /// while leaving the root commit fixed.
    #[cfg(feature = "test-support")]
    fn add_commit(dir: &Path, message: &str, content: &str) {
        std::fs::write(dir.join("file.txt"), content).expect("write fixture file");
        run_git(dir, &["add", "."]);
        let date = "2026-02-01T00:00:00Z";
        let ok = std::process::Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(["commit", "--quiet", "-m", message])
            .env("GIT_AUTHOR_DATE", date)
            .env("GIT_COMMITTER_DATE", date)
            .status()
            .expect("spawn git commit")
            .success();
        assert!(ok, "git commit failed");
    }

    /// The identity is the repo's *root* fingerprint, not its current tip:
    /// advancing `HEAD` with a new commit (root fixed) must leave the identity
    /// unchanged. This is the property a single-commit fixture (where
    /// `HEAD == root`) cannot exercise — it distinguishes "hashes the root"
    /// from "hashes `HEAD`" or "hashes all commits".
    #[test]
    #[cfg(feature = "test-support")]
    fn repo_identity_is_stable_when_head_advances() {
        let dir = tempfile::tempdir().expect("tempdir");
        init_repo_with_commit(dir.path(), "root commit", "first\n");
        let root = root_sha_via_git(dir.path());
        let id_before = repo_identity(dir.path());

        add_commit(dir.path(), "second commit", "second\n");
        let head = root_sha_via_git(dir.path()); // still the root; HEAD moved past it
        assert_eq!(root, head, "the root commit is unchanged by the new tip");
        let head_tip = {
            let out = std::process::Command::new("git")
                .arg("-C")
                .arg(dir.path())
                .args(["rev-parse", "HEAD"])
                .output()
                .expect("spawn git rev-parse");
            String::from_utf8(out.stdout)
                .expect("utf8")
                .trim()
                .to_string()
        };
        assert_ne!(head_tip, root, "HEAD must have advanced past the root");

        let id_after = repo_identity(dir.path());
        assert_eq!(
            id_before, id_after,
            "identity must track the root commit, so it is unchanged when HEAD advances"
        );
    }
}