Skip to content

Commit 447155c

Browse files
committed
feat(codebase): detect low module cohesion
1 parent 3b57e20 commit 447155c

31 files changed

Lines changed: 1018 additions & 110 deletions

crates/reforge-engine/src/api.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ fn validate_public_values(value: &toml::Value) -> Result<()> {
255255
"codebase.max-public-items",
256256
"codebase.max-functions-per-file",
257257
"codebase.max-functions-per-100-lines",
258+
"codebase.min-module-functions",
258259
"codebase.min-repeated-literal-occurrences",
259260
"codebase.min-data-clump-occurrences",
260261
"codebase.churn-window-days",
@@ -277,6 +278,7 @@ fn validate_public_values(value: &toml::Value) -> Result<()> {
277278
}
278279
}
279280
validate_percentage(value, "codebase.max-small-function-ratio")?;
281+
validate_percentage(value, "codebase.min-clustered-function-percent")?;
280282
validate_percentage(value, "dataflow.relay.min-relay-percent")?;
281283
if let Some(candidate) = value_at(value, "codebase.function-similarity")
282284
&& candidate

crates/reforge-engine/src/api/config.rs

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
use super::*;
22

3+
const CODEBASE_CONFIG_KEYS: &[&str] = &[
4+
"preset",
5+
"max-file-lines",
6+
"max-dir-files",
7+
"min-similar-functions",
8+
"min-function-tokens",
9+
"function-similarity",
10+
"max-function-lines",
11+
"max-function-complexity",
12+
"max-nesting-depth",
13+
"max-function-parameters",
14+
"max-type-lines",
15+
"max-type-members",
16+
"max-imports",
17+
"max-public-items",
18+
"max-functions-per-file",
19+
"max-functions-per-100-lines",
20+
"max-small-function-ratio",
21+
"min-module-functions",
22+
"min-clustered-function-percent",
23+
"min-repeated-literal-occurrences",
24+
"min-data-clump-occurrences",
25+
"churn",
26+
"churn-window-days",
27+
"churn-max-commit-lines",
28+
];
29+
330
pub(super) fn validate_public_keys(value: &toml::Value) -> Result<()> {
431
validate_table_keys(
532
value,
@@ -27,34 +54,7 @@ pub(super) fn validate_public_keys(value: &toml::Value) -> Result<()> {
2754
"ignore-paths",
2855
],
2956
)?;
30-
validate_table_keys(
31-
value,
32-
ANALYSIS_CODEBASE,
33-
&[
34-
"preset",
35-
"max-file-lines",
36-
"max-dir-files",
37-
"min-similar-functions",
38-
"min-function-tokens",
39-
"function-similarity",
40-
"max-function-lines",
41-
"max-function-complexity",
42-
"max-nesting-depth",
43-
"max-function-parameters",
44-
"max-type-lines",
45-
"max-type-members",
46-
"max-imports",
47-
"max-public-items",
48-
"max-functions-per-file",
49-
"max-functions-per-100-lines",
50-
"max-small-function-ratio",
51-
"min-repeated-literal-occurrences",
52-
"min-data-clump-occurrences",
53-
"churn",
54-
"churn-window-days",
55-
"churn-max-commit-lines",
56-
],
57-
)?;
57+
validate_table_keys(value, ANALYSIS_CODEBASE, CODEBASE_CONFIG_KEYS)?;
5858
validate_table_keys(
5959
value,
6060
ANALYSIS_DATAFLOW,

crates/reforge-engine/src/api/tests.rs

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ fn repository_dogfood_enables_every_preview_rule_without_changing_defaults() {
7373
let dogfood = Config::parse_toml(&std::fs::read_to_string(path).unwrap()).unwrap();
7474
let registry = crate::detectors::manifest::rule_registry();
7575

76-
assert_eq!(registry.len(), 33);
76+
assert_eq!(registry.len(), 34);
7777
assert_eq!(dogfood.rules.enabled.len(), registry.len());
7878
assert!(
7979
registry.iter().all(|rule| {
@@ -86,6 +86,108 @@ fn repository_dogfood_enables_every_preview_rule_without_changing_defaults() {
8686
assert!(Config::defaults().rules.enabled.is_empty());
8787
}
8888

89+
#[test]
90+
fn low_module_cohesion_thresholds_follow_presets_and_overrides() {
91+
for (preset, functions, percent) in [
92+
("strict", 16, 40),
93+
("balanced", 20, 50),
94+
("relaxed", 30, 60),
95+
] {
96+
let config =
97+
Config::parse_toml(&format!("version = 2\n[codebase]\npreset = \"{preset}\"\n"))
98+
.unwrap();
99+
let resolved = crate::scan::config::effective_scan_config_with(
100+
&crate::execution::EffectiveConfig::default(),
101+
Some(&config.engine),
102+
)
103+
.unwrap();
104+
assert_eq!(resolved.args.min_module_functions, functions);
105+
assert_eq!(resolved.args.min_clustered_function_percent, percent);
106+
}
107+
108+
let config = Config::parse_toml(
109+
"version = 2\n[codebase]\npreset = \"strict\"\nmin-module-functions = 18\nmin-clustered-function-percent = 75\n",
110+
)
111+
.unwrap();
112+
let resolved = crate::scan::config::effective_scan_config_with(
113+
&crate::execution::EffectiveConfig::default(),
114+
Some(&config.engine),
115+
)
116+
.unwrap();
117+
assert_eq!(resolved.args.min_module_functions, 18);
118+
assert_eq!(resolved.args.min_clustered_function_percent, 75);
119+
}
120+
121+
#[test]
122+
fn low_module_cohesion_config_rejects_invalid_boundaries() {
123+
for (setting, expected) in [
124+
("min-module-functions = 0", "positive integer"),
125+
("min-clustered-function-percent = -1", "between 0 and 100"),
126+
("min-clustered-function-percent = 101", "between 0 and 100"),
127+
] {
128+
let error = Config::parse_toml(&format!("version = 2\n[codebase]\n{setting}\n"))
129+
.unwrap_err()
130+
.to_string();
131+
assert!(error.contains(expected), "{error}");
132+
}
133+
Config::parse_toml(
134+
"version = 2\n[codebase]\nmin-module-functions = 1\nmin-clustered-function-percent = 0\n",
135+
)
136+
.unwrap();
137+
}
138+
139+
#[test]
140+
fn low_module_cohesion_report_is_schema_stable_and_python_is_not_applicable() {
141+
let root = std::env::temp_dir().join(format!(
142+
"reforge-low-module-cohesion-{}",
143+
std::process::id()
144+
));
145+
let _ = std::fs::remove_dir_all(&root);
146+
std::fs::create_dir_all(&root).unwrap();
147+
std::fs::write(
148+
root.join("monolith.js"),
149+
"function renderPage(){renderHeader();renderBody();}\nfunction renderHeader(){}\nfunction renderBody(){}\nfunction diffPage(){diffHeader();diffBody();}\nfunction diffHeader(){}\nfunction diffBody(){}\n",
150+
)
151+
.unwrap();
152+
let config = Config::parse_toml(
153+
"version = 2\n[rules]\nenable = [\"reforge.codebase.low_module_cohesion\"]\n[codebase]\nmin-module-functions = 6\nmin-clustered-function-percent = 100\nchurn = \"off\"\n",
154+
)
155+
.unwrap();
156+
let analyze_once = || {
157+
analyze(&AnalyzeOptions {
158+
root: root.clone(),
159+
config: config.clone(),
160+
reproducible: true,
161+
metrics_output: None,
162+
flow_ir_output: None,
163+
})
164+
.unwrap()
165+
};
166+
let first = analyze_once();
167+
let second = analyze_once();
168+
first.validate().unwrap();
169+
let serialized = serde_json::to_vec(&first).unwrap();
170+
let round_trip: Report = serde_json::from_slice(&serialized).unwrap();
171+
round_trip.validate().unwrap();
172+
assert_eq!(serialized, serde_json::to_vec(&second).unwrap());
173+
assert_eq!(first.issues.len(), 1);
174+
assert_eq!(first.issues[0].id, round_trip.issues[0].id);
175+
assert_eq!(
176+
first.issues[0].evidence[0].id,
177+
round_trip.issues[0].evidence[0].id
178+
);
179+
180+
std::fs::remove_file(root.join("monolith.js")).unwrap();
181+
std::fs::write(root.join("monolith.py"), "def render_page():\n pass\n").unwrap();
182+
let python = analyze_once();
183+
assert!(python.issues.is_empty());
184+
assert_eq!(
185+
python.coverage["codebase"].rules["reforge.codebase.low_module_cohesion"].status,
186+
CoverageStatus::NotApplicable
187+
);
188+
std::fs::remove_dir_all(root).unwrap();
189+
}
190+
89191
fn options(root: &Path, config: &Path, enabled: BTreeSet<Analysis>) -> AnalyzeOptions {
90192
let mut config = Config::parse_toml(&std::fs::read_to_string(config).unwrap()).unwrap();
91193
config.set_enabled(enabled).unwrap();
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
const STOP_WORDS: &[&str] = &[
2+
"api", "app", "cmd", "for", "from", "get", "has", "impl", "index", "main", "mod", "new", "old",
3+
"src", "test", "tests", "the", "this", "type", "use", "with",
4+
];
5+
6+
pub(crate) fn split_identifier_words(identifier: &str) -> Vec<String> {
7+
let mut words = Vec::new();
8+
let mut current = String::new();
9+
let mut previous_lowercase = false;
10+
11+
for character in identifier.chars() {
12+
if character == '_' || character == '-' || character == '/' || character == '\\' {
13+
push_word(&mut words, &mut current);
14+
previous_lowercase = false;
15+
continue;
16+
}
17+
if character.is_ascii_uppercase() && previous_lowercase {
18+
push_word(&mut words, &mut current);
19+
}
20+
if character.is_ascii_alphanumeric() {
21+
previous_lowercase = character.is_ascii_lowercase() || character.is_ascii_digit();
22+
current.push(character.to_ascii_lowercase());
23+
} else {
24+
push_word(&mut words, &mut current);
25+
previous_lowercase = false;
26+
}
27+
}
28+
push_word(&mut words, &mut current);
29+
words
30+
}
31+
32+
fn push_word(words: &mut Vec<String>, current: &mut String) {
33+
if current.is_empty() {
34+
return;
35+
}
36+
let word = normalize_word(current);
37+
if !word.is_empty() {
38+
words.push(word);
39+
}
40+
current.clear();
41+
}
42+
43+
pub(crate) fn normalize_word(word: &str) -> String {
44+
let mut normalized = word
45+
.trim_matches(|character: char| !character.is_ascii_alphanumeric())
46+
.to_ascii_lowercase();
47+
if normalized.len() > 4 && normalized.ends_with("ies") {
48+
normalized.truncate(normalized.len() - 3);
49+
normalized.push('y');
50+
} else if normalized.len() > 4 && normalized.ends_with('s') {
51+
normalized.truncate(normalized.len() - 1);
52+
}
53+
normalized
54+
}
55+
56+
pub(crate) fn is_useful_concept_word(word: &str) -> bool {
57+
word.len() > 2
58+
&& !STOP_WORDS.contains(&word)
59+
&& !word.chars().all(|character| character.is_ascii_digit())
60+
}
61+
62+
pub(crate) fn identifier_concepts(identifier: &str) -> std::collections::BTreeSet<String> {
63+
split_identifier_words(identifier)
64+
.into_iter()
65+
.filter(|word| is_useful_concept_word(word))
66+
.collect()
67+
}
68+
69+
#[cfg(test)]
70+
mod tests {
71+
use super::*;
72+
73+
#[test]
74+
fn splits_and_normalizes_identifier_concepts() {
75+
assert_eq!(
76+
identifier_concepts("renderUserEntries"),
77+
std::collections::BTreeSet::from([
78+
"entry".to_string(),
79+
"render".to_string(),
80+
"user".to_string(),
81+
])
82+
);
83+
}
84+
}

crates/reforge-engine/src/detectors/drift/analysis.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ include!("analysis/messages_and_boundaries.rs");
22
include!("analysis/source_syntax.rs");
33
include!("analysis/naming.rs");
44
include!("analysis/constants.rs");
5+
6+
use crate::detectors::concepts::{is_useful_concept_word, normalize_word, split_identifier_words};

crates/reforge-engine/src/detectors/drift/analysis/constants.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,6 @@ pub(super) const GENERIC_BUCKET_WORDS: &[&str] = &[
129129
"common", "helper", "helpers", "lib", "misc", "shared", "util", "utils",
130130
];
131131

132-
pub(super) const STOP_WORDS: &[&str] = &[
133-
"api", "app", "cmd", "for", "from", "get", "has", "impl", "index", "main", "mod", "new", "old",
134-
"src", "test", "tests", "the", "this", "type", "use", "with",
135-
];
136-
137132
pub(super) const CONFIG_KEY_WORDS: &[&str] = &[
138133
"api",
139134
"auth",

crates/reforge-engine/src/detectors/drift/analysis/naming.rs

Lines changed: 0 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -38,65 +38,6 @@ pub(super) fn concept_key(words: &[String], stop_words: &[&str], max_words: usiz
3838
concepts.join(" ")
3939
}
4040

41-
pub(super) fn split_identifier_words(identifier: &str) -> Vec<String> {
42-
let mut words = Vec::new();
43-
let mut current = String::new();
44-
let mut previous_lowercase = false;
45-
46-
for character in identifier.chars() {
47-
if character == '_' || character == '-' || character == '/' || character == '\\' {
48-
push_word(&mut words, &mut current);
49-
previous_lowercase = false;
50-
continue;
51-
}
52-
53-
if character.is_ascii_uppercase() && previous_lowercase {
54-
push_word(&mut words, &mut current);
55-
}
56-
57-
if character.is_ascii_alphanumeric() {
58-
previous_lowercase = character.is_ascii_lowercase() || character.is_ascii_digit();
59-
current.push(character.to_ascii_lowercase());
60-
} else {
61-
push_word(&mut words, &mut current);
62-
previous_lowercase = false;
63-
}
64-
}
65-
push_word(&mut words, &mut current);
66-
67-
words
68-
}
69-
70-
pub(super) fn push_word(words: &mut Vec<String>, current: &mut String) {
71-
if current.is_empty() {
72-
return;
73-
}
74-
let word = normalize_word(current);
75-
if !word.is_empty() {
76-
words.push(word);
77-
}
78-
current.clear();
79-
}
80-
81-
pub(super) fn normalize_word(word: &str) -> String {
82-
let mut normalized = word
83-
.trim_matches(|character: char| !character.is_ascii_alphanumeric())
84-
.to_ascii_lowercase();
85-
if normalized.len() > 4 && normalized.ends_with("ies") {
86-
normalized.truncate(normalized.len() - 3);
87-
normalized.push('y');
88-
} else if normalized.len() > 4 && normalized.ends_with('s') {
89-
normalized.truncate(normalized.len() - 1);
90-
}
91-
normalized
92-
}
93-
94-
pub(super) fn is_useful_concept_word(word: &str) -> bool {
95-
word.len() > 2
96-
&& !STOP_WORDS.contains(&word)
97-
&& !word.chars().all(|character| character.is_ascii_digit())
98-
}
99-
10041
pub(super) fn path_words(path: &Path) -> Vec<String> {
10142
path.components()
10243
.filter_map(|component| component.as_os_str().to_str())

crates/reforge-engine/src/detectors/drift/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::collections::{BTreeMap, BTreeSet};
22
use std::path::{Path, PathBuf};
33

4+
use crate::detectors::concepts::{is_useful_concept_word, split_identifier_words};
45
use crate::detectors::similarity::SourceFile;
56
use crate::evidence_analysis::DetectedEvidenceInput;
67
use crate::model::{DetectedEvidence, DetectedMeasurement, RelatedLocation, Rule};

0 commit comments

Comments
 (0)