1use anyhow::{Context, Result};
14use globset::{Glob, GlobSet, GlobSetBuilder};
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18const DEFAULT_EXCLUDES: &[&str] = &[
20 "**/*.test.ts",
22 "**/*.test.tsx",
23 "**/*.test.js",
24 "**/*.test.jsx",
25 "**/*.spec.ts",
26 "**/*.spec.tsx",
27 "**/*.spec.js",
28 "**/*.spec.jsx",
29 "**/__tests__/**",
30 "**/__mocks__/**",
31 "**/__snapshots__/**",
32 "**/test_*.py",
34 "**/*_test.py",
35 "**/*_test.go",
37 "**/vendor/**",
38 "**/*.pb.go",
39 "**/zz_generated*.go",
40 "**/mock_*.go",
41 "**/node_modules/**",
43 "**/dist/**",
44 "**/build/**",
45 "**/.next/**",
46 "**/.nuxt/**",
47 "**/.output/**",
48 "**/.cache/**",
49 "**/.turbo/**",
50 "**/storybook-static/**",
51 "**/*.min.js",
52 "**/*.min.ts",
53 "**/*.bundle.js",
54 "**/__generated__/**",
55 "**/generated/**",
56 "**/*.generated.ts",
57 "**/*.generated.js",
58 "**/*.pb.ts",
59 "**/*.pb.js",
60 "**/venv/**",
62 "**/.venv/**",
63 "**/__pycache__/**",
64 "**/migrations/**",
66 "**/target/**",
68 "**/out/**",
69 "**/deps/**",
71 "**/third_party/**",
72 "**/thirdparty/**",
73 "**/external/**",
74 "**/extern/**",
75 "**/contrib/**",
76];
77
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct HotspotsConfig {
82 #[serde(default)]
84 pub include: Vec<String>,
85
86 #[serde(default)]
88 pub exclude: Vec<String>,
89
90 #[serde(default)]
92 pub thresholds: Option<ThresholdConfig>,
93
94 #[serde(default)]
96 pub weights: Option<WeightConfig>,
97
98 #[serde(default)]
100 pub warning_thresholds: Option<WarningThresholdConfig>,
101
102 #[serde(default)]
104 pub min_lrs: Option<f64>,
105
106 #[serde(default)]
108 pub top: Option<usize>,
109
110 #[serde(default)]
112 pub scoring: Option<ScoringWeightsConfig>,
113
114 #[serde(default)]
116 pub co_change_window_days: Option<u64>,
117
118 #[serde(default)]
120 pub co_change_min_count: Option<usize>,
121
122 #[serde(default)]
126 pub per_function_touches: Option<bool>,
127
128 #[serde(default)]
132 pub hybrid_touch_threshold: Option<usize>,
133
134 #[serde(default)]
138 pub driver_threshold_percentile: Option<u8>,
139
140 #[serde(default)]
144 pub betweenness_exact_threshold: Option<usize>,
145
146 #[serde(default)]
150 pub betweenness_approx_k: Option<usize>,
151
152 #[serde(default)]
157 pub callgraph_skip_above: Option<usize>,
158
159 #[serde(default)]
161 pub patterns: Option<PatternThresholdsConfig>,
162
163 #[serde(default)]
165 pub policy: Option<PolicyConfig>,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum PolicyMode {
176 Block,
177 Warn,
178 Off,
179}
180
181impl PolicyMode {
182 fn parse(field: &str, s: &str) -> Result<Self> {
183 match s {
184 "block" => Ok(PolicyMode::Block),
185 "warn" => Ok(PolicyMode::Warn),
186 "off" => Ok(PolicyMode::Off),
187 other => anyhow::bail!(
188 "policy.{} must be one of \"block\", \"warn\", \"off\" (got \"{}\")",
189 field,
190 other
191 ),
192 }
193 }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct PolicyConfig {
212 pub critical_introduction: Option<String>,
214 pub critical_introduction_reason: Option<String>,
216 pub excessive_risk_regression: Option<String>,
218 pub excessive_risk_regression_reason: Option<String>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct ThresholdConfig {
226 pub moderate: Option<f64>,
228 pub high: Option<f64>,
230 pub critical: Option<f64>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct WeightConfig {
238 pub cc: Option<f64>,
240 pub nd: Option<f64>,
242 pub fo: Option<f64>,
244 pub ns: Option<f64>,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct ScoringWeightsConfig {
252 pub churn: Option<f64>,
254 pub touch: Option<f64>,
256 pub recency: Option<f64>,
258 pub fan_in: Option<f64>,
260 pub scc: Option<f64>,
262 pub depth: Option<f64>,
264 pub neighbor_churn: Option<f64>,
266 pub burst: Option<f64>,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(deny_unknown_fields)]
273pub struct PatternThresholdsConfig {
274 pub complex_branching_cc: Option<usize>,
275 pub complex_branching_nd: Option<usize>,
276 pub deeply_nested_nd: Option<usize>,
277 pub exit_heavy_ns: Option<usize>,
278 pub god_function_loc: Option<usize>,
279 pub god_function_fo: Option<usize>,
280 pub long_function_loc: Option<usize>,
281 pub churn_magnet_churn: Option<usize>,
282 pub churn_magnet_cc: Option<usize>,
283 pub cyclic_hub_scc: Option<usize>,
284 pub cyclic_hub_fan_in: Option<usize>,
285 pub hub_function_fan_in: Option<usize>,
286 pub hub_function_cc: Option<usize>,
287 pub middle_man_fan_in: Option<usize>,
288 pub middle_man_fo: Option<usize>,
289 pub middle_man_cc_max: Option<usize>,
290 pub neighbor_risk_churn: Option<usize>,
291 pub neighbor_risk_fo: Option<usize>,
292 pub shotgun_target_fan_in: Option<usize>,
293 pub shotgun_target_churn: Option<usize>,
294 pub stale_complex_cc: Option<usize>,
295 pub stale_complex_loc: Option<usize>,
296 pub stale_complex_days: Option<u32>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct WarningThresholdConfig {
303 pub watch_min: Option<f64>,
305 pub watch_max: Option<f64>,
307 pub attention_min: Option<f64>,
309 pub attention_max: Option<f64>,
311 pub rapid_growth_percent: Option<f64>,
313}
314
315#[derive(Debug)]
317pub struct ResolvedConfig {
318 pub include: Option<GlobSet>,
320 pub exclude: GlobSet,
322 pub moderate_threshold: f64,
324 pub high_threshold: f64,
325 pub critical_threshold: f64,
326 pub weight_cc: f64,
328 pub weight_nd: f64,
329 pub weight_fo: f64,
330 pub weight_ns: f64,
331 pub watch_min: f64,
333 pub watch_max: f64,
334 pub attention_min: f64,
335 pub attention_max: f64,
336 pub rapid_growth_percent: f64,
337 pub min_lrs: Option<f64>,
339 pub top_n: Option<usize>,
340 pub co_change_window_days: u64,
342 pub co_change_min_count: usize,
343 pub per_function_touches: bool,
345 pub hybrid_touch_threshold: Option<usize>,
347 pub driver_threshold_percentile: u8,
349 pub betweenness_exact_threshold: usize,
351 pub betweenness_approx_k: usize,
353 pub callgraph_skip_above: usize,
355 pub scoring_weights: crate::scoring::ScoringWeights,
357 pub pattern_thresholds: crate::patterns::Thresholds,
359 pub critical_introduction_mode: PolicyMode,
361 pub critical_introduction_reason: Option<String>,
363 pub excessive_risk_regression_mode: PolicyMode,
365 pub excessive_risk_regression_reason: Option<String>,
367 pub config_path: Option<PathBuf>,
369 pub exclude_is_custom: bool,
372}
373
374impl HotspotsConfig {
375 pub fn validate(&self) -> Result<()> {
377 if let Some(ref t) = self.thresholds {
378 validate_thresholds(t)?;
379 }
380 if let Some(ref w) = self.weights {
381 validate_weights(w)?;
382 }
383 if let Some(ref wt) = self.warning_thresholds {
384 validate_warning_thresholds(wt)?;
385 }
386 if let Some(ref s) = self.scoring {
387 validate_scoring(s)?;
388 }
389 if let Some(ref p) = self.patterns {
390 validate_pattern_thresholds(p)?;
391 }
392 if let Some(ref p) = self.policy {
393 validate_policy_config(p)?;
394 }
395 validate_scalar_fields(self)?;
396 validate_glob_patterns(&self.include, &self.exclude)
397 }
398}
399
400fn validate_scalar_fields(c: &HotspotsConfig) -> Result<()> {
401 if let Some(min) = c.min_lrs {
402 if min < 0.0 {
403 anyhow::bail!("min_lrs must be non-negative (got {})", min);
404 }
405 }
406 if let Some(w) = c.co_change_window_days {
407 if w == 0 {
408 anyhow::bail!("co_change_window_days must be at least 1");
409 }
410 }
411 if let Some(m) = c.co_change_min_count {
412 if m == 0 {
413 anyhow::bail!("co_change_min_count must be at least 1");
414 }
415 }
416 if let Some(p) = c.driver_threshold_percentile {
417 if p == 0 || p >= 100 {
418 anyhow::bail!(
419 "driver_threshold_percentile must be between 1 and 99 (got {})",
420 p
421 );
422 }
423 }
424 if let Some(k) = c.betweenness_approx_k {
425 if k == 0 {
426 anyhow::bail!("betweenness_approx_k must be at least 1");
427 }
428 }
429 Ok(())
430}
431
432fn validate_glob_patterns(include: &[String], exclude: &[String]) -> Result<()> {
433 for pattern in include {
434 Glob::new(pattern).with_context(|| format!("invalid include pattern: {}", pattern))?;
435 }
436 for pattern in exclude {
437 Glob::new(pattern).with_context(|| format!("invalid exclude pattern: {}", pattern))?;
438 }
439 Ok(())
440}
441
442fn validate_thresholds(t: &ThresholdConfig) -> Result<()> {
443 let moderate = t.moderate.unwrap_or(3.0);
444 let high = t.high.unwrap_or(6.0);
445 let critical = t.critical.unwrap_or(9.0);
446 if moderate <= 0.0 {
447 anyhow::bail!("thresholds.moderate must be positive (got {})", moderate);
448 }
449 if high <= 0.0 {
450 anyhow::bail!("thresholds.high must be positive (got {})", high);
451 }
452 if critical <= 0.0 {
453 anyhow::bail!("thresholds.critical must be positive (got {})", critical);
454 }
455 if moderate >= high {
456 anyhow::bail!(
457 "thresholds.moderate ({}) must be less than thresholds.high ({})",
458 moderate,
459 high
460 );
461 }
462 if high >= critical {
463 anyhow::bail!(
464 "thresholds.high ({}) must be less than thresholds.critical ({})",
465 high,
466 critical
467 );
468 }
469 Ok(())
470}
471
472fn validate_policy_config(p: &PolicyConfig) -> Result<()> {
473 if let Some(ref s) = p.critical_introduction {
474 let mode = PolicyMode::parse("critical_introduction", s)?;
475 require_reason_if_downgraded(
476 "critical_introduction",
477 mode,
478 p.critical_introduction_reason.as_deref(),
479 )?;
480 }
481 if let Some(ref s) = p.excessive_risk_regression {
482 let mode = PolicyMode::parse("excessive_risk_regression", s)?;
483 require_reason_if_downgraded(
484 "excessive_risk_regression",
485 mode,
486 p.excessive_risk_regression_reason.as_deref(),
487 )?;
488 }
489 Ok(())
490}
491
492fn require_reason_if_downgraded(field: &str, mode: PolicyMode, reason: Option<&str>) -> Result<()> {
496 if mode == PolicyMode::Block {
497 return Ok(());
498 }
499 match reason {
500 Some(r) if !r.trim().is_empty() => Ok(()),
501 _ => anyhow::bail!(
502 "policy.{field}_reason is required when policy.{field} is not \"block\" (explain why this gate is downgraded)",
503 field = field
504 ),
505 }
506}
507
508fn validate_weights(w: &WeightConfig) -> Result<()> {
509 for (name, val) in [("cc", w.cc), ("nd", w.nd), ("fo", w.fo), ("ns", w.ns)] {
510 if let Some(v) = val {
511 if v < 0.0 {
512 anyhow::bail!("weights.{} must be non-negative (got {})", name, v);
513 }
514 if v > 10.0 {
515 anyhow::bail!("weights.{} must be at most 10.0 (got {})", name, v);
516 }
517 }
518 }
519
520 let resolved_cc = w.cc.unwrap_or(1.0);
526 let resolved_nd = w.nd.unwrap_or(0.8);
527 let resolved_fo = w.fo.unwrap_or(0.6);
528 let resolved_ns = w.ns.unwrap_or(0.7);
529 if resolved_cc == 0.0 && resolved_nd == 0.0 && resolved_fo == 0.0 && resolved_ns == 0.0 {
530 anyhow::bail!(
531 "weights.cc, weights.nd, weights.fo, and weights.ns cannot all be 0 — this would collapse every function's LRS score to 0 and silently disable risk scoring"
532 );
533 }
534
535 Ok(())
536}
537
538fn validate_warning_thresholds(wt: &WarningThresholdConfig) -> Result<()> {
539 let watch_min = wt.watch_min.unwrap_or(2.5);
540 let watch_max = wt.watch_max.unwrap_or(3.0);
541 let attention_min = wt.attention_min.unwrap_or(5.5);
542 let attention_max = wt.attention_max.unwrap_or(6.0);
543 let rapid_growth = wt.rapid_growth_percent.unwrap_or(50.0);
544 if watch_min <= 0.0 {
545 anyhow::bail!(
546 "warning_thresholds.watch_min must be positive (got {})",
547 watch_min
548 );
549 }
550 if watch_max <= 0.0 {
551 anyhow::bail!(
552 "warning_thresholds.watch_max must be positive (got {})",
553 watch_max
554 );
555 }
556 if attention_min <= 0.0 {
557 anyhow::bail!(
558 "warning_thresholds.attention_min must be positive (got {})",
559 attention_min
560 );
561 }
562 if attention_max <= 0.0 {
563 anyhow::bail!(
564 "warning_thresholds.attention_max must be positive (got {})",
565 attention_max
566 );
567 }
568 if rapid_growth <= 0.0 {
569 anyhow::bail!(
570 "warning_thresholds.rapid_growth_percent must be positive (got {})",
571 rapid_growth
572 );
573 }
574 if watch_min >= watch_max {
575 anyhow::bail!(
576 "warning_thresholds.watch_min ({}) must be less than watch_max ({})",
577 watch_min,
578 watch_max
579 );
580 }
581 if attention_min >= attention_max {
582 anyhow::bail!(
583 "warning_thresholds.attention_min ({}) must be less than attention_max ({})",
584 attention_min,
585 attention_max
586 );
587 }
588 Ok(())
589}
590
591fn validate_scoring(s: &ScoringWeightsConfig) -> Result<()> {
592 for (name, val) in [
593 ("churn", s.churn),
594 ("touch", s.touch),
595 ("recency", s.recency),
596 ("fan_in", s.fan_in),
597 ("scc", s.scc),
598 ("depth", s.depth),
599 ("neighbor_churn", s.neighbor_churn),
600 ("burst", s.burst),
601 ] {
602 if let Some(v) = val {
603 if v < 0.0 {
604 anyhow::bail!("scoring.{} must be non-negative (got {})", name, v);
605 }
606 if v > 10.0 {
607 anyhow::bail!("scoring.{} must be at most 10.0 (got {})", name, v);
608 }
609 }
610 }
611 Ok(())
612}
613
614fn validate_pattern_thresholds(p: &PatternThresholdsConfig) -> Result<()> {
615 let usize_fields: &[(&str, Option<usize>)] = &[
617 ("complex_branching_cc", p.complex_branching_cc),
618 ("complex_branching_nd", p.complex_branching_nd),
619 ("deeply_nested_nd", p.deeply_nested_nd),
620 ("exit_heavy_ns", p.exit_heavy_ns),
621 ("god_function_loc", p.god_function_loc),
622 ("god_function_fo", p.god_function_fo),
623 ("long_function_loc", p.long_function_loc),
624 ("churn_magnet_churn", p.churn_magnet_churn),
625 ("churn_magnet_cc", p.churn_magnet_cc),
626 ("cyclic_hub_scc", p.cyclic_hub_scc),
627 ("cyclic_hub_fan_in", p.cyclic_hub_fan_in),
628 ("hub_function_fan_in", p.hub_function_fan_in),
629 ("hub_function_cc", p.hub_function_cc),
630 ("middle_man_fan_in", p.middle_man_fan_in),
631 ("middle_man_fo", p.middle_man_fo),
632 ("middle_man_cc_max", p.middle_man_cc_max),
633 ("neighbor_risk_churn", p.neighbor_risk_churn),
634 ("neighbor_risk_fo", p.neighbor_risk_fo),
635 ("shotgun_target_fan_in", p.shotgun_target_fan_in),
636 ("shotgun_target_churn", p.shotgun_target_churn),
637 ("stale_complex_cc", p.stale_complex_cc),
638 ("stale_complex_loc", p.stale_complex_loc),
639 ];
640 for (name, val) in usize_fields {
641 if let Some(v) = val {
642 if *v == 0 {
643 anyhow::bail!("patterns.{} must be at least 1 (got 0)", name);
644 }
645 }
646 }
647 if let Some(v) = p.stale_complex_days {
648 if v == 0 {
649 anyhow::bail!("patterns.stale_complex_days must be at least 1 (got 0)");
650 }
651 }
652 Ok(())
653}
654
655impl HotspotsConfig {
656 pub fn resolve(&self) -> Result<ResolvedConfig> {
658 self.validate()?;
659
660 let include = if self.include.is_empty() {
662 None
663 } else {
664 let mut builder = GlobSetBuilder::new();
665 for pattern in &self.include {
666 builder.add(Glob::new(pattern)?);
667 }
668 Some(builder.build()?)
669 };
670
671 let exclude = {
673 let mut builder = GlobSetBuilder::new();
674 for pattern in DEFAULT_EXCLUDES {
675 builder.add(Glob::new(pattern)?);
676 }
677 for pattern in &self.exclude {
678 builder.add(Glob::new(pattern)?);
679 }
680 builder.build()?
681 };
682
683 let (moderate, high, critical) = match &self.thresholds {
684 Some(t) => (
685 t.moderate.unwrap_or(3.0),
686 t.high.unwrap_or(6.0),
687 t.critical.unwrap_or(9.0),
688 ),
689 None => (3.0, 6.0, 9.0),
690 };
691
692 let (w_cc, w_nd, w_fo, w_ns) = match &self.weights {
693 Some(w) => (
694 w.cc.unwrap_or(1.0),
695 w.nd.unwrap_or(0.8),
696 w.fo.unwrap_or(0.6),
697 w.ns.unwrap_or(0.7),
698 ),
699 None => (1.0, 0.8, 0.6, 0.7),
700 };
701
702 let (watch_min, watch_max, attention_min, attention_max, rapid_growth_percent) =
703 match &self.warning_thresholds {
704 Some(wt) => (
705 wt.watch_min.unwrap_or(2.5),
706 wt.watch_max.unwrap_or(3.0),
707 wt.attention_min.unwrap_or(5.5),
708 wt.attention_max.unwrap_or(6.0),
709 wt.rapid_growth_percent.unwrap_or(50.0),
710 ),
711 None => (2.5, 3.0, 5.5, 6.0, 50.0),
712 };
713
714 let scoring_weights = match &self.scoring {
715 Some(s) => {
716 let defaults = crate::scoring::ScoringWeights::default();
717 crate::scoring::ScoringWeights {
718 churn: s.churn.unwrap_or(defaults.churn),
719 touch: s.touch.unwrap_or(defaults.touch),
720 recency: s.recency.unwrap_or(defaults.recency),
721 fan_in: s.fan_in.unwrap_or(defaults.fan_in),
722 scc: s.scc.unwrap_or(defaults.scc),
723 depth: s.depth.unwrap_or(defaults.depth),
724 neighbor_churn: s.neighbor_churn.unwrap_or(defaults.neighbor_churn),
725 burst: s.burst.unwrap_or(defaults.burst),
726 }
727 }
728 None => crate::scoring::ScoringWeights::default(),
729 };
730
731 let pattern_thresholds = match &self.patterns {
732 Some(p) => {
733 let d = crate::patterns::Thresholds::default();
734 crate::patterns::Thresholds {
735 complex_branching_cc: p.complex_branching_cc.unwrap_or(d.complex_branching_cc),
736 complex_branching_nd: p.complex_branching_nd.unwrap_or(d.complex_branching_nd),
737 deeply_nested_nd: p.deeply_nested_nd.unwrap_or(d.deeply_nested_nd),
738 exit_heavy_ns: p.exit_heavy_ns.unwrap_or(d.exit_heavy_ns),
739 god_function_loc: p.god_function_loc.unwrap_or(d.god_function_loc),
740 god_function_fo: p.god_function_fo.unwrap_or(d.god_function_fo),
741 long_function_loc: p.long_function_loc.unwrap_or(d.long_function_loc),
742 churn_magnet_churn: p.churn_magnet_churn.unwrap_or(d.churn_magnet_churn),
743 churn_magnet_cc: p.churn_magnet_cc.unwrap_or(d.churn_magnet_cc),
744 cyclic_hub_scc: p.cyclic_hub_scc.unwrap_or(d.cyclic_hub_scc),
745 cyclic_hub_fan_in: p.cyclic_hub_fan_in.unwrap_or(d.cyclic_hub_fan_in),
746 hub_function_fan_in: p.hub_function_fan_in.unwrap_or(d.hub_function_fan_in),
747 hub_function_cc: p.hub_function_cc.unwrap_or(d.hub_function_cc),
748 middle_man_fan_in: p.middle_man_fan_in.unwrap_or(d.middle_man_fan_in),
749 middle_man_fo: p.middle_man_fo.unwrap_or(d.middle_man_fo),
750 middle_man_cc_max: p.middle_man_cc_max.unwrap_or(d.middle_man_cc_max),
751 neighbor_risk_churn: p.neighbor_risk_churn.unwrap_or(d.neighbor_risk_churn),
752 neighbor_risk_fo: p.neighbor_risk_fo.unwrap_or(d.neighbor_risk_fo),
753 shotgun_target_fan_in: p
754 .shotgun_target_fan_in
755 .unwrap_or(d.shotgun_target_fan_in),
756 shotgun_target_churn: p.shotgun_target_churn.unwrap_or(d.shotgun_target_churn),
757 stale_complex_cc: p.stale_complex_cc.unwrap_or(d.stale_complex_cc),
758 stale_complex_loc: p.stale_complex_loc.unwrap_or(d.stale_complex_loc),
759 stale_complex_days: p.stale_complex_days.unwrap_or(d.stale_complex_days),
760 }
761 }
762 None => crate::patterns::Thresholds::default(),
763 };
764
765 let (
766 critical_introduction_mode,
767 critical_introduction_reason,
768 excessive_risk_regression_mode,
769 excessive_risk_regression_reason,
770 ) = match &self.policy {
771 Some(p) => (
772 match &p.critical_introduction {
773 Some(s) => PolicyMode::parse("critical_introduction", s)?,
774 None => PolicyMode::Block,
775 },
776 p.critical_introduction_reason.clone(),
777 match &p.excessive_risk_regression {
778 Some(s) => PolicyMode::parse("excessive_risk_regression", s)?,
779 None => PolicyMode::Block,
780 },
781 p.excessive_risk_regression_reason.clone(),
782 ),
783 None => (PolicyMode::Block, None, PolicyMode::Block, None),
784 };
785
786 Ok(ResolvedConfig {
787 include,
788 exclude,
789 moderate_threshold: moderate,
790 high_threshold: high,
791 critical_threshold: critical,
792 weight_cc: w_cc,
793 weight_nd: w_nd,
794 weight_fo: w_fo,
795 weight_ns: w_ns,
796 watch_min,
797 watch_max,
798 attention_min,
799 attention_max,
800 rapid_growth_percent,
801 min_lrs: self.min_lrs,
802 top_n: self.top,
803 scoring_weights,
804 pattern_thresholds,
805 critical_introduction_mode,
806 critical_introduction_reason,
807 excessive_risk_regression_mode,
808 excessive_risk_regression_reason,
809 co_change_window_days: self.co_change_window_days.unwrap_or(90),
810 co_change_min_count: self.co_change_min_count.unwrap_or(3),
811 per_function_touches: self.per_function_touches.unwrap_or(false),
812 hybrid_touch_threshold: self.hybrid_touch_threshold,
813 driver_threshold_percentile: self.driver_threshold_percentile.unwrap_or(75),
814 betweenness_exact_threshold: self.betweenness_exact_threshold.unwrap_or(2000),
815 betweenness_approx_k: self.betweenness_approx_k.unwrap_or(256),
816 callgraph_skip_above: self.callgraph_skip_above.unwrap_or(usize::MAX),
817 config_path: None,
818 exclude_is_custom: !self.exclude.is_empty(),
819 })
820 }
821}
822
823impl ResolvedConfig {
824 pub fn should_include(&self, path: &Path) -> bool {
826 let path_str = path.to_string_lossy();
827
828 if self.exclude.is_match(path_str.as_ref()) {
830 return false;
831 }
832
833 if let Some(ref include) = self.include {
835 return include.is_match(path_str.as_ref());
836 }
837
838 true
839 }
840
841 pub fn defaults() -> Result<Self> {
843 HotspotsConfig::default().resolve()
844 }
845}
846
847pub fn discover_config(project_root: &Path) -> Result<Option<(HotspotsConfig, PathBuf)>> {
856 let rc_path = project_root.join(".hotspotsrc.json");
858 if rc_path.exists() {
859 let config = load_config_file(&rc_path)?;
860 return Ok(Some((config, rc_path)));
861 }
862
863 let config_path = project_root.join("hotspots.config.json");
865 if config_path.exists() {
866 let config = load_config_file(&config_path)?;
867 return Ok(Some((config, config_path)));
868 }
869
870 let pkg_path = project_root.join("package.json");
872 if pkg_path.exists() {
873 if let Some(config) = load_from_package_json(&pkg_path)? {
874 return Ok(Some((config, pkg_path)));
875 }
876 }
877
878 Ok(None)
879}
880
881pub fn load_config_file(path: &Path) -> Result<HotspotsConfig> {
883 let content = std::fs::read_to_string(path)
884 .with_context(|| format!("failed to read config file: {}", path.display()))?;
885
886 let config: HotspotsConfig = serde_json::from_str(&content)
887 .with_context(|| format!("failed to parse config file: {}", path.display()))?;
888
889 config
890 .validate()
891 .with_context(|| format!("invalid config in: {}", path.display()))?;
892
893 Ok(config)
894}
895
896fn load_from_package_json(path: &Path) -> Result<Option<HotspotsConfig>> {
898 let content = std::fs::read_to_string(path)
899 .with_context(|| format!("failed to read {}", path.display()))?;
900
901 let pkg: serde_json::Value = serde_json::from_str(&content)
902 .with_context(|| format!("failed to parse {}", path.display()))?;
903
904 match pkg.get("hotspots") {
905 Some(hotspots_value) => {
906 let config: HotspotsConfig = serde_json::from_value(hotspots_value.clone())
907 .with_context(|| format!("invalid hotspots config in {}", path.display()))?;
908 config
909 .validate()
910 .with_context(|| format!("invalid hotspots config in {}", path.display()))?;
911 Ok(Some(config))
912 }
913 None => Ok(None),
914 }
915}
916
917pub fn load_and_resolve(project_root: &Path, config_path: Option<&Path>) -> Result<ResolvedConfig> {
923 let (config, source_path) = if let Some(path) = config_path {
924 let config = load_config_file(path)?;
925 (config, Some(path.to_path_buf()))
926 } else {
927 match discover_config(project_root)? {
928 Some((config, path)) => (config, Some(path)),
929 None => (HotspotsConfig::default(), None),
930 }
931 };
932
933 let mut resolved = config.resolve()?;
934 resolved.config_path = source_path;
935 Ok(resolved)
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941 use std::fs;
942
943 #[test]
944 fn test_default_config_is_valid() {
945 let config = HotspotsConfig::default();
946 config.validate().expect("default config should be valid");
947 let resolved = config.resolve().expect("default config should resolve");
948 assert!(resolved.include.is_none());
949 assert_eq!(resolved.weight_cc, 1.0);
950 assert_eq!(resolved.weight_nd, 0.8);
951 assert_eq!(resolved.weight_fo, 0.6);
952 assert_eq!(resolved.weight_ns, 0.7);
953 assert_eq!(resolved.moderate_threshold, 3.0);
954 assert_eq!(resolved.high_threshold, 6.0);
955 assert_eq!(resolved.critical_threshold, 9.0);
956 }
957
958 #[test]
959 fn test_parse_minimal_config() {
960 let json = r#"{}"#;
961 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
962 config.validate().unwrap();
963 }
964
965 #[test]
966 fn test_parse_full_config() {
967 let json = r#"{
968 "include": ["src/**/*.ts", "src/**/*.tsx"],
969 "exclude": ["**/*.test.ts", "**/node_modules/**"],
970 "thresholds": {
971 "moderate": 4.0,
972 "high": 7.0,
973 "critical": 10.0
974 },
975 "weights": {
976 "cc": 1.2,
977 "nd": 0.9,
978 "fo": 0.5,
979 "ns": 0.8
980 },
981 "min_lrs": 2.0,
982 "top": 20
983 }"#;
984 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
985 config.validate().unwrap();
986 let resolved = config.resolve().unwrap();
987 assert!(resolved.include.is_some());
988 assert_eq!(resolved.moderate_threshold, 4.0);
989 assert_eq!(resolved.high_threshold, 7.0);
990 assert_eq!(resolved.critical_threshold, 10.0);
991 assert_eq!(resolved.weight_cc, 1.2);
992 assert_eq!(resolved.min_lrs, Some(2.0));
993 assert_eq!(resolved.top_n, Some(20));
994 }
995
996 #[test]
997 fn test_reject_unknown_fields() {
998 let json = r#"{"unknown_field": true}"#;
999 let result: Result<HotspotsConfig, _> = serde_json::from_str(json);
1000 assert!(result.is_err(), "unknown fields should be rejected");
1001 }
1002
1003 #[test]
1004 fn test_reject_negative_weight() {
1005 let json = r#"{"weights": {"cc": -1.0}}"#;
1006 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1007 assert!(config.validate().is_err());
1008 }
1009
1010 #[test]
1011 fn test_reject_weight_over_10() {
1012 let json = r#"{"weights": {"cc": 11.0}}"#;
1013 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1014 assert!(config.validate().is_err());
1015 }
1016
1017 #[test]
1018 fn test_reject_all_zero_weights() {
1019 let json = r#"{"weights": {"cc": 0.0, "nd": 0.0, "fo": 0.0, "ns": 0.0}}"#;
1022 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1023 assert!(config.validate().is_err());
1024 }
1025
1026 #[test]
1027 fn test_accept_partial_zero_weights() {
1028 let json = r#"{"weights": {"cc": 0.0, "nd": 0.0, "fo": 0.0, "ns": 1.0}}"#;
1031 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1032 assert!(config.validate().is_ok());
1033 }
1034
1035 #[test]
1036 fn test_accept_unset_weights_use_nonzero_defaults() {
1037 let json = r#"{}"#;
1040 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1041 assert!(config.validate().is_ok());
1042 }
1043
1044 #[test]
1045 fn test_reject_negative_threshold() {
1046 let json = r#"{"thresholds": {"moderate": -1.0}}"#;
1047 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1048 assert!(config.validate().is_err());
1049 }
1050
1051 #[test]
1052 fn test_reject_unordered_thresholds() {
1053 let json = r#"{"thresholds": {"moderate": 6.0, "high": 3.0, "critical": 9.0}}"#;
1054 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1055 assert!(config.validate().is_err());
1056 }
1057
1058 #[test]
1059 fn test_reject_invalid_glob_pattern() {
1060 let json = r#"{"include": ["[invalid"]}"#;
1061 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1062 assert!(config.validate().is_err());
1063 }
1064
1065 #[test]
1066 fn test_should_include_default_excludes() {
1067 let resolved = ResolvedConfig::defaults().unwrap();
1068 assert!(!resolved.should_include(Path::new("src/foo.test.ts")));
1069 assert!(!resolved.should_include(Path::new("node_modules/pkg/index.js")));
1070 assert!(!resolved.should_include(Path::new("dist/bundle.js")));
1071 assert!(resolved.should_include(Path::new("src/api.ts")));
1072 assert!(resolved.should_include(Path::new("src/components/Button.tsx")));
1073 assert!(!resolved.should_include(Path::new("deps/jemalloc/src/arena.c")));
1075 assert!(!resolved.should_include(Path::new("third_party/abseil/absl/base/base.cc")));
1076 assert!(!resolved.should_include(Path::new("thirdparty/openssl/ssl/ssl_lib.c")));
1077 assert!(!resolved.should_include(Path::new("external/zlib/deflate.c")));
1078 assert!(!resolved.should_include(Path::new("extern/lua/lvm.c")));
1079 assert!(!resolved.should_include(Path::new("contrib/hiredis/net.c")));
1080 assert!(resolved.should_include(Path::new("src/server.c")));
1082 assert!(resolved.should_include(Path::new("src/networking.c")));
1083 }
1084
1085 #[test]
1086 fn test_should_include_custom_patterns() {
1087 let config: HotspotsConfig = serde_json::from_str(
1088 r#"{
1089 "include": ["src/**/*.ts"],
1090 "exclude": ["src/generated/**"]
1091 }"#,
1092 )
1093 .unwrap();
1094 let resolved = config.resolve().unwrap();
1095 assert!(resolved.should_include(Path::new("src/api.ts")));
1096 assert!(!resolved.should_include(Path::new("lib/util.ts")));
1097 assert!(!resolved.should_include(Path::new("src/generated/types.ts")));
1098 }
1099
1100 #[test]
1101 fn test_discover_hotspotsrc() {
1102 let dir = tempfile::tempdir().unwrap();
1103 let config_path = dir.path().join(".hotspotsrc.json");
1104 fs::write(&config_path, r#"{"min_lrs": 5.0}"#).unwrap();
1105
1106 let result = discover_config(dir.path()).unwrap();
1107 assert!(result.is_some());
1108 let (config, path) = result.unwrap();
1109 assert_eq!(config.min_lrs, Some(5.0));
1110 assert_eq!(path, config_path);
1111 }
1112
1113 #[test]
1114 fn test_discover_hotspots_config_json() {
1115 let dir = tempfile::tempdir().unwrap();
1116 let config_path = dir.path().join("hotspots.config.json");
1117 fs::write(&config_path, r#"{"top": 10}"#).unwrap();
1118
1119 let result = discover_config(dir.path()).unwrap();
1120 assert!(result.is_some());
1121 let (config, _) = result.unwrap();
1122 assert_eq!(config.top, Some(10));
1123 }
1124
1125 #[test]
1126 fn test_discover_package_json() {
1127 let dir = tempfile::tempdir().unwrap();
1128 let pkg_path = dir.path().join("package.json");
1129 fs::write(
1130 &pkg_path,
1131 r#"{
1132 "name": "my-project",
1133 "version": "1.0.0",
1134 "hotspots": {
1135 "exclude": ["**/*.test.ts"],
1136 "min_lrs": 3.0
1137 }
1138 }"#,
1139 )
1140 .unwrap();
1141
1142 let result = discover_config(dir.path()).unwrap();
1143 assert!(result.is_some());
1144 let (config, _) = result.unwrap();
1145 assert_eq!(config.min_lrs, Some(3.0));
1146 assert_eq!(config.exclude, vec!["**/*.test.ts"]);
1147 }
1148
1149 #[test]
1150 fn test_discover_package_json_without_hotspots_key() {
1151 let dir = tempfile::tempdir().unwrap();
1152 let pkg_path = dir.path().join("package.json");
1153 fs::write(&pkg_path, r#"{"name": "my-project", "version": "1.0.0"}"#).unwrap();
1154
1155 let result = discover_config(dir.path()).unwrap();
1156 assert!(result.is_none());
1157 }
1158
1159 #[test]
1160 fn test_discover_priority_order() {
1161 let dir = tempfile::tempdir().unwrap();
1162
1163 fs::write(dir.path().join(".hotspotsrc.json"), r#"{"min_lrs": 1.0}"#).unwrap();
1165 fs::write(
1166 dir.path().join("hotspots.config.json"),
1167 r#"{"min_lrs": 2.0}"#,
1168 )
1169 .unwrap();
1170
1171 let result = discover_config(dir.path()).unwrap();
1172 let (config, _) = result.unwrap();
1173 assert_eq!(
1174 config.min_lrs,
1175 Some(1.0),
1176 ".hotspotsrc.json should take priority"
1177 );
1178 }
1179
1180 #[test]
1181 fn test_no_config_returns_none() {
1182 let dir = tempfile::tempdir().unwrap();
1183 let result = discover_config(dir.path()).unwrap();
1184 assert!(result.is_none());
1185 }
1186
1187 #[test]
1188 fn test_load_and_resolve_defaults() {
1189 let dir = tempfile::tempdir().unwrap();
1190 let resolved = load_and_resolve(dir.path(), None).unwrap();
1191 assert!(resolved.config_path.is_none());
1192 assert_eq!(resolved.weight_cc, 1.0);
1193 }
1194
1195 #[test]
1196 fn test_load_and_resolve_explicit_path() {
1197 let dir = tempfile::tempdir().unwrap();
1198 let config_path = dir.path().join("custom.json");
1199 fs::write(&config_path, r#"{"weights": {"cc": 2.0}}"#).unwrap();
1200
1201 let resolved = load_and_resolve(dir.path(), Some(&config_path)).unwrap();
1202 assert_eq!(resolved.weight_cc, 2.0);
1203 assert_eq!(resolved.config_path, Some(config_path));
1204 assert!(!resolved.exclude_is_custom);
1208 }
1209
1210 #[test]
1211 fn test_exclude_is_custom_when_config_sets_exclude() {
1212 let dir = tempfile::tempdir().unwrap();
1213 let config_path = dir.path().join("custom.json");
1214 fs::write(&config_path, r#"{"exclude": ["**/legacy/**"]}"#).unwrap();
1215
1216 let resolved = load_and_resolve(dir.path(), Some(&config_path)).unwrap();
1217 assert!(resolved.exclude_is_custom);
1218 }
1219
1220 #[test]
1221 fn test_exclude_is_not_custom_with_no_config_file() {
1222 let dir = tempfile::tempdir().unwrap();
1223 let resolved = load_and_resolve(dir.path(), None).unwrap();
1224 assert!(!resolved.exclude_is_custom);
1225 }
1226
1227 #[test]
1228 fn test_partial_weights_use_defaults_for_rest() {
1229 let json = r#"{"weights": {"cc": 2.0}}"#;
1230 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1231 let resolved = config.resolve().unwrap();
1232 assert_eq!(resolved.weight_cc, 2.0);
1233 assert_eq!(resolved.weight_nd, 0.8); assert_eq!(resolved.weight_fo, 0.6); assert_eq!(resolved.weight_ns, 0.7); }
1237
1238 #[test]
1239 fn test_partial_thresholds_use_defaults_for_rest() {
1240 let json = r#"{"thresholds": {"critical": 12.0}}"#;
1241 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1242 let resolved = config.resolve().unwrap();
1243 assert_eq!(resolved.moderate_threshold, 3.0); assert_eq!(resolved.high_threshold, 6.0); assert_eq!(resolved.critical_threshold, 12.0);
1246 }
1247
1248 #[test]
1249 fn test_default_warning_thresholds() {
1250 let config = HotspotsConfig::default();
1251 let resolved = config.resolve().unwrap();
1252 assert_eq!(resolved.watch_min, 2.5);
1253 assert_eq!(resolved.watch_max, 3.0);
1254 assert_eq!(resolved.attention_min, 5.5);
1255 assert_eq!(resolved.attention_max, 6.0);
1256 assert_eq!(resolved.rapid_growth_percent, 50.0);
1257 }
1258
1259 #[test]
1260 fn test_parse_warning_thresholds() {
1261 let json = r#"{
1262 "warning_thresholds": {
1263 "watch_min": 2.0,
1264 "watch_max": 3.5,
1265 "attention_min": 5.0,
1266 "attention_max": 7.0,
1267 "rapid_growth_percent": 75.0
1268 }
1269 }"#;
1270 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1271 config.validate().unwrap();
1272 let resolved = config.resolve().unwrap();
1273 assert_eq!(resolved.watch_min, 2.0);
1274 assert_eq!(resolved.watch_max, 3.5);
1275 assert_eq!(resolved.attention_min, 5.0);
1276 assert_eq!(resolved.attention_max, 7.0);
1277 assert_eq!(resolved.rapid_growth_percent, 75.0);
1278 }
1279
1280 #[test]
1281 fn test_partial_warning_thresholds_use_defaults() {
1282 let json = r#"{"warning_thresholds": {"rapid_growth_percent": 100.0}}"#;
1283 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1284 let resolved = config.resolve().unwrap();
1285 assert_eq!(resolved.watch_min, 2.5); assert_eq!(resolved.watch_max, 3.0); assert_eq!(resolved.attention_min, 5.5); assert_eq!(resolved.attention_max, 6.0); assert_eq!(resolved.rapid_growth_percent, 100.0);
1290 }
1291
1292 #[test]
1293 fn test_reject_negative_watch_min() {
1294 let json = r#"{"warning_thresholds": {"watch_min": -1.0}}"#;
1295 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1296 assert!(config.validate().is_err());
1297 }
1298
1299 #[test]
1300 fn test_reject_negative_rapid_growth() {
1301 let json = r#"{"warning_thresholds": {"rapid_growth_percent": -10.0}}"#;
1302 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1303 assert!(config.validate().is_err());
1304 }
1305
1306 #[test]
1307 fn test_reject_unordered_watch_thresholds() {
1308 let json = r#"{"warning_thresholds": {"watch_min": 3.0, "watch_max": 2.0}}"#;
1309 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1310 assert!(config.validate().is_err());
1311 }
1312
1313 #[test]
1314 fn test_reject_unordered_attention_thresholds() {
1315 let json = r#"{"warning_thresholds": {"attention_min": 7.0, "attention_max": 5.0}}"#;
1316 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1317 assert!(config.validate().is_err());
1318 }
1319
1320 #[test]
1321 fn test_full_config_with_warnings() {
1322 let json = r#"{
1323 "thresholds": {
1324 "moderate": 3.0,
1325 "high": 6.0,
1326 "critical": 9.0
1327 },
1328 "warning_thresholds": {
1329 "watch_min": 2.5,
1330 "watch_max": 3.0,
1331 "attention_min": 5.5,
1332 "attention_max": 6.0,
1333 "rapid_growth_percent": 50.0
1334 }
1335 }"#;
1336 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1337 config.validate().unwrap();
1338 let resolved = config.resolve().unwrap();
1339 assert_eq!(resolved.watch_min, 2.5);
1340 assert_eq!(resolved.watch_max, 3.0);
1341 assert_eq!(resolved.attention_min, 5.5);
1342 assert_eq!(resolved.attention_max, 6.0);
1343 assert_eq!(resolved.rapid_growth_percent, 50.0);
1344 }
1345
1346 #[test]
1347 fn test_scoring_weights_defaults() {
1348 let config = HotspotsConfig::default();
1349 let resolved = config.resolve().unwrap();
1350 let defaults = crate::scoring::ScoringWeights::default();
1351 assert_eq!(resolved.scoring_weights.churn, defaults.churn);
1352 assert_eq!(resolved.scoring_weights.touch, defaults.touch);
1353 assert_eq!(resolved.scoring_weights.recency, defaults.recency);
1354 assert_eq!(resolved.scoring_weights.fan_in, defaults.fan_in);
1355 assert_eq!(resolved.scoring_weights.scc, defaults.scc);
1356 assert_eq!(resolved.scoring_weights.depth, defaults.depth);
1357 assert_eq!(
1358 resolved.scoring_weights.neighbor_churn,
1359 defaults.neighbor_churn
1360 );
1361 }
1362
1363 #[test]
1364 fn test_scoring_weights_from_config() {
1365 let json = r#"{
1366 "scoring": {
1367 "churn": 0.8,
1368 "touch": 0.5,
1369 "fan_in": 0.6
1370 }
1371 }"#;
1372 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1373 config.validate().unwrap();
1374 let resolved = config.resolve().unwrap();
1375 assert_eq!(resolved.scoring_weights.churn, 0.8);
1376 assert_eq!(resolved.scoring_weights.touch, 0.5);
1377 assert_eq!(resolved.scoring_weights.fan_in, 0.6);
1378 let defaults = crate::scoring::ScoringWeights::default();
1380 assert_eq!(resolved.scoring_weights.recency, defaults.recency);
1381 assert_eq!(resolved.scoring_weights.scc, defaults.scc);
1382 }
1383
1384 #[test]
1385 fn test_reject_negative_scoring_weight() {
1386 let json = r#"{"scoring": {"churn": -0.1}}"#;
1387 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1388 assert!(config.validate().is_err());
1389 }
1390
1391 #[test]
1392 fn test_reject_scoring_weight_over_10() {
1393 let json = r#"{"scoring": {"fan_in": 11.0}}"#;
1394 let config: HotspotsConfig = serde_json::from_str(json).unwrap();
1395 assert!(config.validate().is_err());
1396 }
1397}