use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use git2::{Repository, Sort};
use serde::{Deserialize, Serialize};
use crate::core::config::Config;
use crate::core::finding::{Finding, IntoFindings, Location};
use crate::core::severity::Severity;
use crate::feature::{decorate, Feature, FeatureKind, FeatureMeta, HotspotIndex};
use crate::observer::shared::file_role::{file_role, FileRole};
use crate::observer::shared::walk::{since_cutoff, ExcludeMatcher};
use crate::observer::{impl_workspace_builder, ObservationMeta, Observer};
use crate::observers::ObserverReports;
impl_workspace_builder!(ChangeCouplingObserver);
const BULK_COMMIT_FILE_LIMIT: usize = 50;
#[derive(Debug, Clone)]
pub struct ChangeCouplingObserver {
pub enabled: bool,
pub excluded: Vec<String>,
pub since_days: u32,
pub min_coupling: u32,
pub min_lift: f64,
pub symmetric_threshold: f64,
pub workspace: Option<PathBuf>,
}
impl Default for ChangeCouplingObserver {
fn default() -> Self {
Self {
enabled: false,
excluded: Vec::new(),
since_days: 0,
min_coupling: 0,
min_lift: 0.0,
symmetric_threshold: default_symmetric_threshold(),
workspace: None,
}
}
}
impl ChangeCouplingObserver {
#[must_use]
pub fn from_config(cfg: &Config) -> Self {
Self {
enabled: cfg.metrics.is_enabled("change_coupling"),
excluded: cfg.exclude_lines(),
since_days: cfg.git.since_days,
min_coupling: cfg.metrics.change_coupling.min_coupling,
min_lift: cfg.metrics.change_coupling.min_lift,
symmetric_threshold: cfg.metrics.change_coupling.symmetric_threshold,
workspace: None,
}
}
#[must_use]
pub fn scan(&self, root: &Path) -> ChangeCouplingReport {
let mut report = ChangeCouplingReport {
since_days: self.since_days,
min_coupling: self.min_coupling,
..ChangeCouplingReport::default()
};
if !self.enabled {
return report;
}
let Ok(repo) = Repository::discover(root) else {
return report;
};
let cutoff_secs = since_cutoff(self.since_days);
let Ok(mut revwalk) = repo.revwalk() else {
return report;
};
if revwalk.set_sorting(Sort::TIME).is_err() || revwalk.push_head().is_err() {
return report;
}
let mut pair_counts: HashMap<(PathBuf, PathBuf), u32> = HashMap::new();
let mut file_commits: HashMap<PathBuf, u32> = HashMap::new();
let mut commits_considered: u32 = 0;
let workspace_target = crate::observer::shared::walk::resolve_workspace_target(
root,
self.workspace.as_deref(),
false,
);
let matcher = ExcludeMatcher::compile(root, &self.excluded)
.expect("exclude patterns validated at config load");
for oid_res in revwalk {
let Ok(oid) = oid_res else {
continue;
};
let Ok(commit) = repo.find_commit(oid) else {
continue;
};
if commit.time().seconds() < cutoff_secs {
break;
}
if Self::absorb_commit(
&repo,
&commit,
workspace_target.as_deref(),
&matcher,
&mut pair_counts,
&mut file_commits,
) {
commits_considered = commits_considered.saturating_add(1);
}
}
let pairs = collect_pairs(
pair_counts,
self.min_coupling,
self.min_lift,
&file_commits,
commits_considered,
self.symmetric_threshold,
);
let file_sums = compute_file_sums(&pairs);
let totals = CouplingTotals {
pairs: pairs.len(),
files: file_sums.len(),
commits_considered,
};
report.pairs = pairs;
report.file_sums = file_sums;
report.totals = totals;
report
}
fn absorb_commit(
repo: &Repository,
commit: &git2::Commit<'_>,
workspace_target: Option<&Path>,
matcher: &ExcludeMatcher,
pair_counts: &mut HashMap<(PathBuf, PathBuf), u32>,
file_commits: &mut HashMap<PathBuf, u32>,
) -> bool {
let Ok(commit_tree) = commit.tree() else {
return false;
};
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let Ok(diff) = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)
else {
return false;
};
let mut paths: BTreeSet<PathBuf> = BTreeSet::new();
for delta in diff.deltas() {
let Some(path) = delta.new_file().path() else {
continue;
};
if path.as_os_str().is_empty() {
continue;
}
if !crate::observer::shared::walk::path_under(path, workspace_target) {
continue;
}
if matcher.is_excluded(path, false) {
continue;
}
paths.insert(path.to_path_buf());
}
if paths.is_empty() || paths.len() > BULK_COMMIT_FILE_LIMIT {
return false;
}
for path in &paths {
let entry = file_commits.entry(path.clone()).or_insert(0);
*entry = entry.saturating_add(1);
}
if paths.len() < 2 {
return true;
}
let ordered: Vec<&PathBuf> = paths.iter().collect();
for (i, a) in ordered.iter().enumerate() {
for b in &ordered[i + 1..] {
let counter = pair_counts.entry(((*a).clone(), (*b).clone())).or_insert(0);
*counter = counter.saturating_add(1);
}
}
true
}
}
#[must_use]
pub fn default_symmetric_threshold() -> f64 {
0.5
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChangeCouplingReport {
pub pairs: Vec<FilePair>,
pub file_sums: Vec<FileSum>,
pub totals: CouplingTotals,
pub since_days: u32,
pub min_coupling: u32,
}
impl ChangeCouplingReport {
#[must_use]
pub fn worst_n_pairs(&self, n: usize) -> Vec<FilePair> {
let mut top = self.pairs.clone();
top.truncate(n);
top
}
#[must_use]
pub fn worst_n_files(&self, n: usize) -> Vec<FileSum> {
let mut top = self.file_sums.clone();
top.truncate(n);
top
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FilePair {
pub a: PathBuf,
pub b: PathBuf,
pub count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direction: Option<PairDirection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<PairClass>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PairClass {
Genuine,
TestSrc,
DocSrc,
Manifest,
Lockfile,
Generated,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PairDirection {
Symmetric,
OneWay { from: PathBuf, to: PathBuf },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileSum {
pub path: PathBuf,
pub sum: u32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CouplingTotals {
pub pairs: usize,
pub files: usize,
pub commits_considered: u32,
}
impl Observer for ChangeCouplingObserver {
type Output = ChangeCouplingReport;
fn meta(&self) -> ObservationMeta {
ObservationMeta {
name: "change_coupling",
version: 1,
}
}
fn observe(&self, project_root: &Path) -> anyhow::Result<Self::Output> {
Ok(self.scan(project_root))
}
}
impl IntoFindings for ChangeCouplingReport {
fn into_findings(&self) -> Vec<Finding> {
self.pairs
.iter()
.map(|pair| {
let b_str = pair.b.to_string_lossy().into_owned();
let primary = Location {
file: pair.a.clone(),
line: None,
symbol: Some(b_str.clone()),
};
let (metric, arrow) = render_metric_and_arrow(pair);
let summary = format!(
"co-changed {} times: {} {arrow} {}",
pair.count,
pair.a.display(),
b_str,
);
Finding::new(metric, primary, summary, &format!("count:{}", pair.count))
.with_locations(vec![Location::file(pair.b.clone())])
})
.collect()
}
}
fn render_metric_and_arrow(pair: &FilePair) -> (&'static str, &'static str) {
match &pair.direction {
Some(PairDirection::Symmetric) => {
(Finding::METRIC_CHANGE_COUPLING_SYMMETRIC, "↔ (symmetric)")
}
Some(PairDirection::OneWay { from, .. }) if from == &pair.a => {
(Finding::METRIC_CHANGE_COUPLING, "→")
}
Some(PairDirection::OneWay { .. }) => (Finding::METRIC_CHANGE_COUPLING, "←"),
None => (Finding::METRIC_CHANGE_COUPLING, "↔"),
}
}
fn collect_pairs(
pair_counts: HashMap<(PathBuf, PathBuf), u32>,
min_coupling: u32,
min_lift: f64,
file_commits: &HashMap<PathBuf, u32>,
commits_considered: u32,
symmetric_threshold: f64,
) -> Vec<FilePair> {
let mut pairs: Vec<FilePair> = pair_counts
.into_iter()
.filter(|(_, count)| *count >= min_coupling)
.filter(|((a, b), count)| {
let count_a = file_commits.get(a).copied().unwrap_or(0).max(*count);
let count_b = file_commits.get(b).copied().unwrap_or(0).max(*count);
lift(*count, count_a, count_b, commits_considered) >= min_lift
})
.map(|((a, b), count)| {
let count_a = file_commits.get(&a).copied().unwrap_or(0).max(count);
let count_b = file_commits.get(&b).copied().unwrap_or(0).max(count);
let direction =
classify_direction(&a, &b, count, count_a, count_b, symmetric_threshold);
FilePair {
a,
b,
count,
direction: Some(direction),
class: None,
}
})
.collect();
pairs.sort_by(|x, y| {
y.count
.cmp(&x.count)
.then_with(|| x.a.cmp(&y.a))
.then_with(|| x.b.cmp(&y.b))
});
pairs
}
fn lift(pair_count: u32, count_a: u32, count_b: u32, commits_considered: u32) -> f64 {
if commits_considered == 0 || count_a == 0 || count_b == 0 {
return f64::INFINITY;
}
#[allow(clippy::cast_precision_loss)]
{
f64::from(pair_count) * f64::from(commits_considered)
/ (f64::from(count_a) * f64::from(count_b))
}
}
fn classify_direction(
a: &Path,
b: &Path,
pair_count: u32,
count_a: u32,
count_b: u32,
symmetric_threshold: f64,
) -> PairDirection {
#[allow(clippy::cast_precision_loss)]
let p_b_given_a = f64::from(pair_count) / f64::from(count_a);
#[allow(clippy::cast_precision_loss)]
let p_a_given_b = f64::from(pair_count) / f64::from(count_b);
if p_b_given_a >= symmetric_threshold && p_a_given_b >= symmetric_threshold {
PairDirection::Symmetric
} else if p_a_given_b > p_b_given_a {
PairDirection::OneWay {
from: a.to_path_buf(),
to: b.to_path_buf(),
}
} else {
PairDirection::OneWay {
from: b.to_path_buf(),
to: a.to_path_buf(),
}
}
}
fn compute_file_sums(pairs: &[FilePair]) -> Vec<FileSum> {
let mut sums: BTreeMap<PathBuf, u32> = BTreeMap::new();
for pair in pairs {
let a = sums.entry(pair.a.clone()).or_insert(0);
*a = a.saturating_add(pair.count);
let b = sums.entry(pair.b.clone()).or_insert(0);
*b = b.saturating_add(pair.count);
}
let mut file_sums: Vec<FileSum> = sums
.into_iter()
.map(|(path, sum)| FileSum { path, sum })
.collect();
file_sums.sort_by(|x, y| y.sum.cmp(&x.sum).then_with(|| x.path.cmp(&y.path)));
file_sums
}
pub(crate) fn classify_and_filter(report: &mut ChangeCouplingReport, primary_lang: Option<&str>) {
for pair in &mut report.pairs {
pair.class = Some(classify_pair(&pair.a, &pair.b, primary_lang));
}
report.pairs.retain(|p| {
!matches!(
p.class,
Some(PairClass::Lockfile | PairClass::Generated | PairClass::Manifest)
)
});
report.file_sums = compute_file_sums(&report.pairs);
report.totals.pairs = report.pairs.len();
report.totals.files = report.file_sums.len();
}
fn classify_pair(a: &Path, b: &Path, primary_lang: Option<&str>) -> PairClass {
let a_role = file_role(a, primary_lang);
let b_role = file_role(b, primary_lang);
if matches!(a_role, FileRole::Lockfile) || matches!(b_role, FileRole::Lockfile) {
return PairClass::Lockfile;
}
if matches!(a_role, FileRole::Generated) || matches!(b_role, FileRole::Generated) {
return PairClass::Generated;
}
if is_manifest_pair(a, b) {
return PairClass::Manifest;
}
if matches!(a_role, FileRole::Test) || matches!(b_role, FileRole::Test) {
return PairClass::TestSrc;
}
if matches!(a_role, FileRole::Doc) || matches!(b_role, FileRole::Doc) {
return PairClass::DocSrc;
}
PairClass::Genuine
}
fn is_manifest_pair(a: &Path, b: &Path) -> bool {
let (Some(a_parent), Some(b_parent)) = (a.parent(), b.parent()) else {
return false;
};
if a_parent != b_parent {
return false;
}
let a_name = a.file_name().and_then(|f| f.to_str()).unwrap_or_default();
let b_name = b.file_name().and_then(|f| f.to_str()).unwrap_or_default();
is_module_manifest(a_name) ^ is_module_manifest(b_name)
}
fn is_module_manifest(basename: &str) -> bool {
matches!(
basename,
"mod.rs" | "lib.rs" | "main.rs" | "__init__.py" | "index.ts" | "index.tsx" | "index.js"
)
}
pub struct ChangeCouplingFeature;
impl Feature for ChangeCouplingFeature {
fn meta(&self) -> FeatureMeta {
FeatureMeta {
name: "change_coupling",
version: 1,
kind: FeatureKind::Observer,
}
}
fn enabled(&self, cfg: &Config) -> bool {
cfg.metrics.is_enabled("change_coupling")
}
fn lower(
&self,
reports: &ObserverReports,
cfg: &Config,
cal: &crate::core::calibration::Calibration,
hotspot: &HotspotIndex,
) -> Vec<Finding> {
let Some(cc) = reports.change_coupling.as_ref() else {
return Vec::new();
};
let workspaces = cfg.project.workspaces.as_slice();
let cross_policy = cfg.metrics.change_coupling.cross_workspace;
let mut out = Vec::with_capacity(cc.pairs.len());
for (pair, mut finding) in cc.pairs.iter().zip(cc.into_findings()) {
if matches!(pair.class, Some(PairClass::TestSrc | PairClass::DocSrc)) {
let is_test_pair = matches!(pair.class, Some(PairClass::TestSrc));
let drift_threshold = if is_test_pair && cfg.features.test.enabled {
cal.calibration.change_coupling.as_ref().map(|c| c.p50)
} else {
None
};
let metric = match drift_threshold {
Some(p50) if p50.is_finite() && f64::from(pair.count) < p50 => {
Finding::METRIC_CHANGE_COUPLING_DRIFT
}
_ => Finding::METRIC_CHANGE_COUPLING_EXPECTED,
};
finding.metric = metric.into();
finding.id = Finding::make_id(
&finding.metric,
&finding.location,
&format!("count:{}", pair.count),
);
out.push(decorate(finding, Severity::Medium, hotspot));
continue;
}
let ws_a = (!workspaces.is_empty())
.then(|| crate::core::config::assign_workspace(&pair.a, workspaces))
.flatten();
let cross = ws_a.is_some_and(|a| {
crate::core::config::assign_workspace(&pair.b, workspaces).is_some_and(|b| a != b)
});
if cross {
match cross_policy {
crate::core::config::CrossWorkspacePolicy::Hide => continue,
crate::core::config::CrossWorkspacePolicy::Surface => {
finding.metric = Finding::METRIC_CHANGE_COUPLING_CROSS_WORKSPACE.into();
finding.id = Finding::make_id(
&finding.metric,
&finding.location,
&format!("count:{}", pair.count),
);
}
}
}
let cal_cc = cal.metrics_for_workspace(ws_a).change_coupling.as_ref();
let severity = cal_cc.map_or(Severity::Ok, |c| c.classify(f64::from(pair.count)));
out.push(decorate(finding, severity, hotspot));
}
out
}
}
#[cfg(test)]
mod pair_class_tests {
use super::*;
fn pair(a: &str, b: &str, count: u32) -> FilePair {
let (a, b) = if a <= b { (a, b) } else { (b, a) };
FilePair {
a: PathBuf::from(a),
b: PathBuf::from(b),
count,
direction: None,
class: None,
}
}
fn report(pairs: Vec<FilePair>) -> ChangeCouplingReport {
ChangeCouplingReport {
pairs,
file_sums: Vec::new(),
totals: CouplingTotals::default(),
since_days: 90,
min_coupling: 3,
}
}
#[test]
fn lockfile_pair_dropped_for_typescript_project() {
let mut r = report(vec![
pair("package.json", "bun.lock", 5),
pair("src/foo.ts", "src/bar.ts", 4),
]);
classify_and_filter(&mut r, Some("typescript"));
assert_eq!(r.pairs.len(), 1, "lockfile pair must be dropped");
assert_eq!(r.pairs[0].class, Some(PairClass::Genuine));
}
#[test]
fn generic_lock_suffix_matches_any_language() {
let mut r = report(vec![
pair("Cargo.lock", "src/lib.rs", 5),
pair("target/debug/build", "src/lib.rs", 5),
]);
classify_and_filter(&mut r, Some("rust"));
assert_eq!(r.pairs.len(), 0, "lockfile + target/ both dropped");
}
#[test]
fn scala_target_pair_dropped() {
let mut r = report(vec![
pair(
"target/scala-3.3.0/classes/Foo.class",
"src/main/scala/Foo.scala",
6,
),
pair("project/target/streams", "src/main/scala/Foo.scala", 6),
pair("src/main/scala/Foo.scala", "src/main/scala/Bar.scala", 4),
]);
classify_and_filter(&mut r, Some("scala"));
assert_eq!(r.pairs.len(), 1, "target/ pairs dropped, src pair kept");
assert_eq!(r.pairs[0].a, PathBuf::from("src/main/scala/Bar.scala"));
}
#[test]
fn dist_artefact_pair_dropped() {
let mut r = report(vec![
pair("src/index.ts", "dist/index.css", 8),
pair("src/foo.ts", "src/bar.ts", 4),
]);
classify_and_filter(&mut r, Some("typescript"));
assert_eq!(r.pairs.len(), 1);
assert_eq!(r.pairs[0].a, PathBuf::from("src/bar.ts"));
}
#[test]
fn doc_pair_demoted_not_dropped() {
let mut r = report(vec![
pair("CLAUDE.md", "src/lib.rs", 7),
pair("src/foo.rs", "src/bar.rs", 4),
]);
classify_and_filter(&mut r, Some("rust"));
assert_eq!(r.pairs.len(), 2, "DocSrc pairs stay in the report");
let doc_pair = r
.pairs
.iter()
.find(|p| p.a == Path::new("CLAUDE.md") || p.b == Path::new("CLAUDE.md"))
.expect("doc pair preserved");
assert_eq!(doc_pair.class, Some(PairClass::DocSrc));
}
#[test]
fn test_pair_demoted_not_dropped() {
let mut r = report(vec![pair("src/foo.test.ts", "src/foo.ts", 6)]);
classify_and_filter(&mut r, Some("typescript"));
assert_eq!(r.pairs.len(), 1);
assert_eq!(r.pairs[0].class, Some(PairClass::TestSrc));
}
#[test]
fn manifest_pair_dropped() {
let mut r = report(vec![pair(
"crates/cli/src/observer/mod.rs",
"crates/cli/src/observer/loc.rs",
5,
)]);
classify_and_filter(&mut r, Some("rust"));
assert_eq!(r.pairs.len(), 0, "mod.rs ↔ sibling dropped as Manifest");
}
#[test]
fn manifest_pair_requires_same_directory() {
let mut r = report(vec![pair(
"crates/cli/src/observer/mod.rs",
"crates/cli/src/cli.rs",
5,
)]);
classify_and_filter(&mut r, Some("rust"));
assert_eq!(r.pairs.len(), 1);
assert_eq!(r.pairs[0].class, Some(PairClass::Genuine));
}
#[test]
fn into_findings_emits_for_all_pairs_lower_filters_demoted() {
let mut r = report(vec![
pair("README.md", "src/lib.rs", 6),
pair("src/foo.rs", "src/bar.rs", 5),
]);
classify_and_filter(&mut r, Some("rust"));
let findings = r.into_findings();
assert_eq!(findings.len(), 2);
}
#[test]
fn python_specific_lockfiles() {
for lf in ["poetry.lock", "Pipfile.lock", "uv.lock"] {
let mut r = report(vec![pair(lf, "src/main.py", 5)]);
classify_and_filter(&mut r, Some("python"));
assert_eq!(r.pairs.len(), 0, "{lf} must be dropped");
}
}
#[test]
fn unknown_language_still_drops_generic_lockfiles() {
let mut r = report(vec![pair("foo.lock", "src/main.kt", 5)]);
classify_and_filter(&mut r, None);
assert_eq!(r.pairs.len(), 0);
}
#[test]
fn lift_above_chance() {
assert!((lift(5, 5, 5, 10) - 2.0).abs() < 1e-9);
}
#[test]
fn lift_below_chance_for_widespread_files() {
assert!((lift(5, 50, 50, 100) - 0.2).abs() < 1e-9);
}
#[test]
fn lift_handles_empty_universe() {
assert!(lift(0, 0, 0, 0).is_infinite());
assert!(lift(5, 0, 5, 100).is_infinite());
}
mod cross_workspace_lower {
use super::*;
use crate::core::calibration::Calibration;
use crate::core::config::{
Config, CrossWorkspacePolicy, WorkspaceMetricsOverlay, WorkspaceOverlay,
};
use crate::feature::{Feature, HotspotIndex};
use crate::observers::ObserverReports;
fn cfg(workspaces: Vec<&str>, policy: CrossWorkspacePolicy) -> Config {
let mut c = Config::default();
c.metrics.change_coupling.cross_workspace = policy;
c.project.workspaces = workspaces
.into_iter()
.map(|p| WorkspaceOverlay {
path: p.into(),
language: None,
exclude_paths: Vec::new(),
metrics: WorkspaceMetricsOverlay::default(),
})
.collect();
c
}
fn reports_with(pairs: Vec<FilePair>) -> ObserverReports {
ObserverReports {
change_coupling: Some(report(pairs)),
..ObserverReports::default()
}
}
#[test]
fn demoted_pair_class_is_emitted_as_expected_advisory() {
let mut p = pair("src/foo.rs", "src/foo.test.rs", 5);
p.class = Some(PairClass::TestSrc);
let r = reports_with(vec![p]);
let c = cfg(Vec::new(), CrossWorkspacePolicy::Surface);
let f = ChangeCouplingFeature.lower(
&r,
&c,
&Calibration::default(),
&HotspotIndex::default(),
);
assert_eq!(f.len(), 1);
assert_eq!(f[0].metric, Finding::METRIC_CHANGE_COUPLING_EXPECTED);
assert_eq!(f[0].severity, Severity::Medium);
assert!(f[0].id.starts_with("change_coupling.expected:"));
}
#[test]
fn cross_workspace_pair_retagged_when_surface() {
let pairs = vec![pair("packages/api/server.ts", "packages/web/client.ts", 5)];
let r = reports_with(pairs);
let c = cfg(
vec!["packages/api", "packages/web"],
CrossWorkspacePolicy::Surface,
);
let f = ChangeCouplingFeature.lower(
&r,
&c,
&Calibration::default(),
&HotspotIndex::default(),
);
assert_eq!(f.len(), 1);
assert_eq!(f[0].metric, "change_coupling.cross_workspace");
assert!(f[0].id.starts_with("change_coupling.cross_workspace:"));
}
#[test]
fn cross_workspace_pair_dropped_when_hide() {
let pairs = vec![
pair("packages/api/server.ts", "packages/web/client.ts", 5),
pair("packages/api/a.ts", "packages/api/b.ts", 5),
];
let r = reports_with(pairs);
let c = cfg(
vec!["packages/api", "packages/web"],
CrossWorkspacePolicy::Hide,
);
let f = ChangeCouplingFeature.lower(
&r,
&c,
&Calibration::default(),
&HotspotIndex::default(),
);
assert_eq!(f.len(), 1, "only the same-workspace pair survives");
assert_eq!(f[0].metric, "change_coupling");
}
#[test]
fn pair_with_one_unscoped_file_not_cross_workspace() {
let pairs = vec![pair("packages/api/server.ts", "scripts/ci.sh", 5)];
let r = reports_with(pairs);
let c = cfg(vec!["packages/api"], CrossWorkspacePolicy::Surface);
let f = ChangeCouplingFeature.lower(
&r,
&c,
&Calibration::default(),
&HotspotIndex::default(),
);
assert_eq!(f.len(), 1);
assert_eq!(f[0].metric, "change_coupling");
}
#[test]
fn no_workspaces_declared_means_no_cross_workspace_tag() {
let pairs = vec![pair("a/x.ts", "b/y.ts", 5)];
let r = reports_with(pairs);
let c = Config::default();
let f = ChangeCouplingFeature.lower(
&r,
&c,
&Calibration::default(),
&HotspotIndex::default(),
);
assert_eq!(f.len(), 1);
assert_eq!(f[0].metric, "change_coupling");
}
}
}