Skip to content

Commit 6aaef45

Browse files
authored
Merge pull request #20 from Eilodon/claude/calm-mcp-connection-9tk1f4
Add boundary globs, hotspot norm/bot signals, real preset filtering, …
2 parents dbeabff + 3bdf9d4 commit 6aaef45

14 files changed

Lines changed: 901 additions & 34 deletions

File tree

crates/calm-core/src/analysis/boundaries.rs

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ use rusqlite::Connection;
22
use serde::{Deserialize, Serialize};
33

44
/// One declared architecture rule: files under `from` must not import files
5-
/// under `to`. Matching is a simple path-prefix check on project-relative,
6-
/// forward-slash paths (the same format `import_edges.from_path`/`to_path`
7-
/// already use) — deliberately not glob/regex, so a rule reads exactly like
8-
/// the directory boundary it describes and there's no pattern syntax to get
9-
/// wrong. `from`/`to` are directory prefixes (e.g. `"crates/calm-core/src/indexer/"`),
10-
/// not exact file paths.
5+
/// under `to`. `from`/`to` are project-relative, forward-slash paths (the
6+
/// same format `import_edges.from_path`/`to_path` already use). A pattern
7+
/// containing a glob metacharacter (`*`, `?`, `[`) is matched with `globset`
8+
/// against the whole path (e.g. `"crates/*/src/indexer/**"`); otherwise it's
9+
/// treated as a plain directory prefix via `starts_with` (e.g.
10+
/// `"crates/calm-core/src/indexer/"`) — existing prefix-style rules keep
11+
/// working unchanged.
1112
#[derive(Debug, Clone, Deserialize, Serialize)]
1213
pub struct BoundaryRule {
1314
pub from: String,
@@ -30,6 +31,34 @@ pub struct BoundaryViolation {
3031
/// is reported per (edge, rule) pair — the same edge can violate more than
3132
/// one rule if rules overlap, which is surfaced rather than deduplicated so
3233
/// each rule's own `reason` is visible.
34+
enum PathMatcher {
35+
Prefix(String),
36+
Glob(globset::GlobMatcher),
37+
}
38+
39+
impl PathMatcher {
40+
/// A pattern containing a glob metacharacter is compiled with `globset`;
41+
/// otherwise (including invalid glob syntax) it falls back to a plain
42+
/// `starts_with` prefix check, so existing prefix-style rules are
43+
/// unaffected and a typo'd glob degrades to its literal prefix rather
44+
/// than silently matching nothing.
45+
fn new(pattern: &str) -> Self {
46+
if pattern.contains(['*', '?', '[']) {
47+
if let Ok(glob) = globset::Glob::new(pattern) {
48+
return PathMatcher::Glob(glob.compile_matcher());
49+
}
50+
}
51+
PathMatcher::Prefix(pattern.to_string())
52+
}
53+
54+
fn matches(&self, path: &str) -> bool {
55+
match self {
56+
PathMatcher::Prefix(prefix) => path.starts_with(prefix.as_str()),
57+
PathMatcher::Glob(matcher) => matcher.is_match(path),
58+
}
59+
}
60+
}
61+
3362
pub fn check_boundaries(
3463
conn: &Connection,
3564
rules: &[BoundaryRule],
@@ -46,10 +75,15 @@ pub fn check_boundaries(
4675
.filter_map(|r| r.ok())
4776
.collect();
4877

78+
let matchers: Vec<(PathMatcher, PathMatcher)> = rules
79+
.iter()
80+
.map(|rule| (PathMatcher::new(&rule.from), PathMatcher::new(&rule.to)))
81+
.collect();
82+
4983
let mut violations = Vec::new();
5084
for (from_path, to_path) in &edges {
51-
for rule in rules {
52-
if from_path.starts_with(&rule.from) && to_path.starts_with(&rule.to) {
85+
for (rule, (from_matcher, to_matcher)) in rules.iter().zip(&matchers) {
86+
if from_matcher.matches(from_path) && to_matcher.matches(to_path) {
5387
violations.push(BoundaryViolation {
5488
from_path: from_path.clone(),
5589
to_path: to_path.clone(),
@@ -183,4 +217,43 @@ mod tests {
183217
let violations = check_boundaries(&conn, &rules).unwrap();
184218
assert_eq!(violations.len(), 2);
185219
}
220+
221+
#[test]
222+
fn test_glob_rule_matches_nested_paths() {
223+
let conn = test_conn();
224+
insert_import(&conn, "crates/calm-core/src/indexer/foo.rs", "crates/calm-server/src/tools/orient.rs");
225+
let rules = vec![BoundaryRule {
226+
from: "crates/*/src/indexer/**".into(),
227+
to: "crates/*/src/tools/**".into(),
228+
reason: "indexer must not import server tools".into(),
229+
}];
230+
let violations = check_boundaries(&conn, &rules).unwrap();
231+
assert_eq!(violations.len(), 1);
232+
}
233+
234+
#[test]
235+
fn test_glob_rule_does_not_match_unrelated_path() {
236+
let conn = test_conn();
237+
insert_import(&conn, "crates/calm-core/src/other/foo.rs", "crates/calm-server/src/tools/orient.rs");
238+
let rules = vec![BoundaryRule {
239+
from: "crates/*/src/indexer/**".into(),
240+
to: "crates/*/src/tools/**".into(),
241+
reason: "indexer must not import server tools".into(),
242+
}];
243+
let violations = check_boundaries(&conn, &rules).unwrap();
244+
assert!(violations.is_empty());
245+
}
246+
247+
#[test]
248+
fn test_invalid_glob_falls_back_to_literal_prefix() {
249+
let conn = test_conn();
250+
insert_import(&conn, "a/[unclosed/x.py", "b/y.py");
251+
let rules = vec![BoundaryRule {
252+
from: "a/[unclosed".into(),
253+
to: "b/".into(),
254+
reason: "invalid glob syntax degrades to prefix match".into(),
255+
}];
256+
let violations = check_boundaries(&conn, &rules).unwrap();
257+
assert_eq!(violations.len(), 1);
258+
}
186259
}

crates/calm-core/src/analysis/coverage.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ impl CoverageData {
2323
}
2424
}
2525

26-
const COVERAGE_SEARCH_PATHS: &[(&str, &str)] = &[
26+
pub const COVERAGE_SEARCH_PATHS: &[(&str, &str)] = &[
2727
("lcov.info", "lcov"),
2828
("coverage/lcov.info", "lcov"),
2929
(".nyc_output/lcov.info", "lcov"),

crates/calm-core/src/analysis/hotspot.rs

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,31 @@ use crate::config::HotspotsConfig;
88
#[derive(Debug, Clone)]
99
pub struct ChurnInfo {
1010
pub commit_count: i64,
11+
/// Commits whose author looks like a bot account (see `is_bot_author`) —
12+
/// counted separately from `commit_count` so `churn_source` can tell
13+
/// human-driven churn from CI/dependency-bump noise.
14+
pub bot_commit_count: i64,
1115
pub authors: HashSet<String>,
1216
pub last_changed: Option<String>,
1317
}
1418

19+
impl ChurnInfo {
20+
/// "unknown" with no churn data at all (e.g. git unavailable),
21+
/// "human" when no commit came from a bot account, "bot_dominated" when
22+
/// every commit did, "mixed" otherwise.
23+
pub fn churn_source(&self) -> &'static str {
24+
if self.commit_count == 0 {
25+
"unknown"
26+
} else if self.bot_commit_count == 0 {
27+
"human"
28+
} else if self.bot_commit_count >= self.commit_count {
29+
"bot_dominated"
30+
} else {
31+
"mixed"
32+
}
33+
}
34+
}
35+
1536
#[derive(Debug, Clone)]
1637
pub struct ComplexityInfo {
1738
pub symbol_count: i64,
@@ -41,6 +62,13 @@ pub struct HotspotEntry {
4162
pub language: String,
4263
pub churn: ChurnInfo,
4364
pub complexity: ComplexityInfo,
65+
/// Churn share (0-1) of `hotspot_score`'s numerator, normalized against
66+
/// the busiest candidate file this run — 0.0 when git is unavailable
67+
/// (no churn signal at all, not "no churn").
68+
pub norm_churn: f64,
69+
/// Complexity share (0-1) of `hotspot_score`'s numerator, normalized
70+
/// against the most complex candidate file this run.
71+
pub norm_compl: f64,
4472
pub hotspot_score: f64,
4573
pub risk_level: String,
4674
pub top_symbols: Option<Vec<HotspotSymbol>>,
@@ -87,6 +115,7 @@ pub fn compute_hotspots(
87115
path.clone(),
88116
ChurnInfo {
89117
commit_count: 0,
118+
bot_commit_count: 0,
90119
authors: HashSet::new(),
91120
last_changed: None,
92121
},
@@ -141,10 +170,13 @@ pub fn compute_hotspots(
141170
.iter()
142171
.filter_map(|(path, churn)| {
143172
let cm = complexity_map.get(path)?;
173+
// Computed unconditionally (not just when `git_available`) so
174+
// callers can see the churn share even when it didn't factor
175+
// into `hotspot_score` — 0.0 uniformly when git is unavailable,
176+
// since every candidate's `commit_count` is 0 in that branch.
177+
let norm_churn = churn_scores.get(path.as_str()).copied().unwrap_or(0.0) / max_churn;
144178
let norm_compl = compl_scores.get(path.as_str()).copied().unwrap_or(0.0) / max_compl;
145179
let score = if git_available {
146-
let norm_churn =
147-
churn_scores.get(path.as_str()).copied().unwrap_or(0.0) / max_churn;
148180
norm_churn * norm_compl
149181
} else {
150182
norm_compl
@@ -165,6 +197,8 @@ pub fn compute_hotspots(
165197
language: cm.language.clone(),
166198
churn: churn.clone(),
167199
complexity: cm.clone(),
200+
norm_churn: (norm_churn * 10000.0).round() / 10000.0,
201+
norm_compl: (norm_compl * 10000.0).round() / 10000.0,
168202
hotspot_score: (score * 10000.0).round() / 10000.0,
169203
risk_level: risk_level.to_string(),
170204
top_symbols: None,
@@ -283,19 +317,31 @@ fn collect_git_churn(project_root: &Path, since: &str) -> (HashMap<String, Churn
283317
for path in &commit.files {
284318
let entry = churn_map.entry(path.clone()).or_insert_with(|| ChurnInfo {
285319
commit_count: 0,
320+
bot_commit_count: 0,
286321
authors: HashSet::new(),
287322
// H-1 fix: last_changed is Option<String>, None instead of ""
288323
last_changed: commit.date.clone(),
289324
});
290325
entry.commit_count += 1;
291326
if let Some(ref author) = commit.author {
292327
entry.authors.insert(author.clone());
328+
if is_bot_author(author) {
329+
entry.bot_commit_count += 1;
330+
}
293331
}
294332
}
295333
}
296334
(churn_map, true)
297335
}
298336

337+
/// GitHub's bot accounts (dependabot[bot], renovate[bot], github-actions[bot],
338+
/// etc.) all carry `[bot]` in their noreply author email (`%ae`), which is
339+
/// what `commits_with_files` captures — this substring check covers them
340+
/// without hardcoding a specific bot allowlist.
341+
fn is_bot_author(author: &str) -> bool {
342+
author.contains("[bot]")
343+
}
344+
299345
fn collect_complexity(conn: &Connection) -> HashMap<String, ComplexityInfo> {
300346
// is_test = 0: test functions structurally call production code, so they
301347
// almost always end up with coreness > 0 despite never being called
@@ -512,6 +558,100 @@ mod tests {
512558
assert_eq!(output.hotspots[0].churn.commit_count, 2);
513559
}
514560

561+
#[test]
562+
fn test_churn_source_classifies_human_bot_mixed_and_unknown() {
563+
let unknown = ChurnInfo {
564+
commit_count: 0,
565+
bot_commit_count: 0,
566+
authors: HashSet::new(),
567+
last_changed: None,
568+
};
569+
assert_eq!(unknown.churn_source(), "unknown");
570+
571+
let human = ChurnInfo {
572+
commit_count: 3,
573+
bot_commit_count: 0,
574+
authors: HashSet::new(),
575+
last_changed: None,
576+
};
577+
assert_eq!(human.churn_source(), "human");
578+
579+
let bot_dominated = ChurnInfo {
580+
commit_count: 3,
581+
bot_commit_count: 3,
582+
authors: HashSet::new(),
583+
last_changed: None,
584+
};
585+
assert_eq!(bot_dominated.churn_source(), "bot_dominated");
586+
587+
let mixed = ChurnInfo {
588+
commit_count: 4,
589+
bot_commit_count: 1,
590+
authors: HashSet::new(),
591+
last_changed: None,
592+
};
593+
assert_eq!(mixed.churn_source(), "mixed");
594+
}
595+
596+
#[test]
597+
fn test_is_bot_author_matches_bot_suffix() {
598+
assert!(is_bot_author(
599+
"49699333+dependabot[bot]@users.noreply.github.com"
600+
));
601+
assert!(is_bot_author(
602+
"29139614+renovate[bot]@users.noreply.github.com"
603+
));
604+
assert!(!is_bot_author("jane@example.com"));
605+
}
606+
607+
#[test]
608+
fn test_collect_git_churn_tallies_bot_commits_separately() {
609+
let dir = tempfile::tempdir().unwrap();
610+
run_git(dir.path(), &["init", "-q"]);
611+
run_git(dir.path(), &["config", "user.email", "human@example.com"]);
612+
run_git(dir.path(), &["config", "user.name", "Human"]);
613+
std::fs::write(dir.path().join("f.py"), "x = 1\n").unwrap();
614+
run_git(dir.path(), &["add", "f.py"]);
615+
run_git(dir.path(), &["commit", "-q", "-m", "init"]);
616+
617+
run_git(
618+
dir.path(),
619+
&[
620+
"config",
621+
"user.email",
622+
"49699333+dependabot[bot]@users.noreply.github.com",
623+
],
624+
);
625+
run_git(dir.path(), &["config", "user.name", "dependabot[bot]"]);
626+
std::fs::write(dir.path().join("f.py"), "x = 2\n").unwrap();
627+
run_git(dir.path(), &["commit", "-q", "-am", "bump"]);
628+
629+
let (churn_map, git_available) = collect_git_churn(dir.path(), "1 year");
630+
assert!(git_available);
631+
let entry = churn_map.get("f.py").unwrap();
632+
assert_eq!(entry.commit_count, 2);
633+
assert_eq!(entry.bot_commit_count, 1);
634+
assert_eq!(entry.churn_source(), "mixed");
635+
}
636+
637+
#[test]
638+
fn test_norm_compl_and_norm_churn_are_exposed_without_git() {
639+
let conn = setup_db();
640+
let dir = tempfile::tempdir().unwrap();
641+
insert_symbol(&conn, "a.func1", "/a.py", 0, false, 0);
642+
insert_symbol(&conn, "b.func1", "/b.py", 10, true, 5);
643+
insert_symbol(&conn, "b.func2", "/b.py", 2, false, 1);
644+
645+
let config = HotspotsConfig::default();
646+
let output = compute_hotspots(dir.path(), &conn, &config, 10, "6 months ago", 2, false);
647+
assert!(!output.git_available);
648+
// No churn signal at all when git is unavailable.
649+
assert!(output.hotspots.iter().all(|h| h.norm_churn == 0.0));
650+
// b.py is the most complex candidate, so its complexity share is 1.0.
651+
let b = output.hotspots.iter().find(|h| h.path == "/b.py").unwrap();
652+
assert_eq!(b.norm_compl, 1.0);
653+
}
654+
515655
/// Regression: `compute_absolute_hotspot_risk` must NOT saturate to
516656
/// ~1.0 just because one file is *relatively* the busiest in a small,
517657
/// healthy repo — unlike `compute_hotspots`' `hotspot_score` (min-max

0 commit comments

Comments
 (0)