mod common;
use std::collections::BTreeMap;
const LIMIT: usize = 400;
const ALLOWLIST: &str = include_str!("module_size_allowlist.txt");
const ALLOWLIST_DIGESTS: &[(&str, u64)] = &[
("apps/server", 4114979320796990468),
("rust/core", 218310759456605933),
("rust/export", 15791359419451037914),
("rust/geometry", 17164311728163718683),
("rust/processing", 7633784028779437211),
("rust/wasm-bindings", 11372642225568989008),
];
const FILE_FLOOR: usize = 240;
fn repo_root() -> Option<std::path::PathBuf> {
let mut dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf();
loop {
if dir.join("rust").is_dir() && dir.join("apps").is_dir() {
return Some(dir);
}
if !dir.pop() {
return None;
}
}
}
fn collect_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => panic!(
"module-size ratchet: {} does not exist. Refusing to treat a missing \
directory as one holding no .rs files - a walk root that is not \
there means this gate is looking in the wrong place, not that the \
tree is clean.",
dir.display()
),
Err(err) => panic!(
"module-size ratchet: {} could not be read ({err}). Refusing to treat \
an unreadable directory as one holding no .rs files.",
dir.display()
),
};
for entry in entries {
let entry = entry.unwrap_or_else(|err| {
panic!(
"module-size ratchet: an entry of {} could not be read ({err}). \
Refusing to walk past a file this gate could not classify.",
dir.display()
)
});
let path = entry.path();
if path.is_dir() {
let skip = matches!(
path.file_name().and_then(|n| n.to_str()),
Some("target" | "node_modules" | ".git" | "dist" | "build")
);
if !skip {
collect_rs_files(&path, out);
}
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(path);
}
}
}
fn is_exempt(rel: &str) -> bool {
let base = rel.rsplit('/').next().unwrap_or(rel);
rel.contains("/generated/")
|| rel.contains("/tests/")
|| rel.contains("/examples/")
|| rel.contains("/benches/")
|| rel.contains("/fuzz/")
|| base == "tests.rs"
|| base.ends_with("_tests.rs")
|| base.ends_with("_test.rs")
}
fn parse_allowlist() -> std::collections::HashMap<String, usize> {
let mut map = std::collections::HashMap::new();
for line in ALLOWLIST.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (budget, path) = line
.split_once(char::is_whitespace)
.unwrap_or_else(|| panic!("module_size_allowlist.txt: malformed line: {line:?}"));
let budget: usize = budget
.trim()
.parse()
.unwrap_or_else(|_| panic!("module_size_allowlist.txt: bad budget in: {line:?}"));
map.insert(path.trim().to_string(), budget);
}
map
}
fn line_count(path: &std::path::Path) -> usize {
match std::fs::read_to_string(path) {
Ok(s) => s.lines().count(),
Err(err) => panic!(
"module-size ratchet: {} could not be read ({err}). Refusing to count \
an unreadable file as 0 lines - 0 is under every budget, so the file \
would pass the ratchet without ever being measured.",
path.display()
),
}
}
fn evaluate(
files: &[(String, usize)],
allowlist: &std::collections::HashMap<String, usize>,
) -> (Vec<String>, Vec<String>) {
let mut new_offenders = Vec::new(); let mut grew = Vec::new(); for (rel, lines) in files {
match allowlist.get(rel) {
Some(&budget) if *lines > budget => {
grew.push(format!(" {rel}: {lines} lines, budget {budget}"));
}
Some(_) => {}
None if *lines > LIMIT => new_offenders.push(format!(" {rel}: {lines} lines")),
None => {}
}
}
new_offenders.sort();
grew.sort();
(new_offenders, grew)
}
#[test]
fn no_module_grows_past_its_ratchet_budget() {
let Some(root) = repo_root() else {
common::refuse_to_skip_in_ci("module-size ratchet");
eprintln!("repo root not found (packaged context) - skipping module-size ratchet");
return;
};
let allowlist = parse_allowlist();
let mut paths = Vec::new();
for top in ["rust", "apps"] {
collect_rs_files(&root.join(top), &mut paths);
}
let files: Vec<(String, usize)> = paths
.iter()
.map(|p| {
(
p.strip_prefix(&root).unwrap_or(p).to_string_lossy().replace('\\', "/"),
line_count(p),
)
})
.filter(|(rel, _)| !is_exempt(rel))
.collect();
assert!(
files.len() >= FILE_FLOOR,
"module-size ratchet walked rust/ and apps/ and reached only {} non-exempt \
.rs file(s); the floor is {FILE_FLOOR}. Refusing a vacuous pass: every \
check below iterates this list, so a count this low means the walk \
stopped working, not that the modules went away. If crates were \
genuinely removed, lower FILE_FLOOR in the same commit.",
files.len()
);
let seen: std::collections::HashMap<&String, usize> =
files.iter().map(|(r, n)| (r, *n)).collect();
for rel in allowlist.keys() {
match seen.get(rel) {
None => eprintln!(
"note: allowlist row {rel:?} no longer matches a tracked file (gone or now exempt); remove it"
),
Some(&lines) if lines <= LIMIT => eprintln!(
"note: {rel} is now {lines} <= {LIMIT} lines; remove its allowlist row (the total should trend down)"
),
Some(_) => {}
}
}
let (new_offenders, grew) = evaluate(&files, &allowlist);
let mut msg = String::new();
if !new_offenders.is_empty() {
msg.push_str(&format!(
"New non-generated .rs file(s) over {LIMIT} lines with no allowlist row.\n\
Split them (AGENTS.md rule), or - only with a written justification - \
add a row to rust/processing/tests/module_size_allowlist.txt:\n{}\n",
new_offenders.join("\n")
));
}
if !grew.is_empty() {
msg.push_str(&format!(
"Allowlisted file(s) grew PAST their recorded budget. Shrink or split \
instead of raising the budget:\n{}\n",
grew.join("\n")
));
}
assert!(msg.is_empty(), "\n{msg}");
}
#[test]
fn evaluate_fires_on_new_god_file_and_over_budget() {
let mut allowlist = std::collections::HashMap::new();
allowlist.insert("rust/a/big.rs".to_string(), 500usize);
allowlist.insert("rust/a/grown.rs".to_string(), 600usize);
let files = vec![
("rust/a/small.rs".to_string(), 399), ("rust/a/at_limit.rs".to_string(), 400), ("rust/a/new_god.rs".to_string(), 401), ("rust/a/big.rs".to_string(), 500), ("rust/a/grown.rs".to_string(), 601), ];
let (new_offenders, grew) = evaluate(&files, &allowlist);
assert_eq!(new_offenders, vec![" rust/a/new_god.rs: 401 lines"]);
assert_eq!(grew, vec![" rust/a/grown.rs: 601 lines, budget 600"]);
}
#[test]
fn evaluate_is_clean_when_within_budget() {
let mut allowlist = std::collections::HashMap::new();
allowlist.insert("rust/a/big.rs".to_string(), 500usize);
let files = vec![
("rust/a/small.rs".to_string(), 12),
("rust/a/big.rs".to_string(), 480), ];
let (new_offenders, grew) = evaluate(&files, &allowlist);
assert!(new_offenders.is_empty() && grew.is_empty());
}
#[test]
fn allowlist_is_well_formed_and_over_limit() {
let stale: Vec<_> = parse_allowlist()
.into_iter()
.filter(|(_, budget)| *budget <= LIMIT)
.map(|(rel, budget)| format!(" {rel}: budget {budget} <= {LIMIT}"))
.collect();
assert!(
stale.is_empty(),
"allowlist rows at or under the {LIMIT}-line limit (delete them):\n{}",
stale.join("\n")
);
}
#[test]
fn allowlist_digest_is_pinned() {
let rows = parse_allowlist();
let actual = allowlist_digests();
let pinned: BTreeMap<String, u64> = ALLOWLIST_DIGESTS
.iter()
.map(|(s, d)| ((*s).to_string(), *d))
.collect();
let total: usize = rows.values().sum();
let drifted: Vec<String> = actual
.iter()
.filter(|(scope, d)| pinned.get(*scope) != Some(*d))
.map(|(scope, d)| format!(" (\"{scope}\", {d}),"))
.collect();
let orphaned: Vec<&str> = pinned
.keys()
.filter(|s| !actual.contains_key(*s))
.map(String::as_str)
.collect();
assert!(
drifted.is_empty() && orphaned.is_empty(),
"module_size_allowlist.txt has {} rows, budgets total {total}, and {} scope(s) \
disagree with ALLOWLIST_DIGESTS in module_size_ratchet.rs.\n\n\
Raising a budget loosens the ratchet, so it must be visible. Set these entries \
in the SAME commit and say in the PR why the module cannot be split:\n\n{}\n\n\
Orphaned pins (no rows left, delete them): {:?}\n\n\
Only the scopes listed moved; every other entry stays as it is.",
rows.len(),
drifted.len() + orphaned.len(),
drifted.join("\n"),
orphaned
);
}
#[test]
fn allowlist_scope_matches_the_shared_vectors() {
let raw = include_str!("fixtures/module_size_scope_vectors.json");
let doc: serde_json::Value = serde_json::from_str(raw).expect("fixture is valid JSON");
let cases = doc["cases"].as_array().expect("fixture has a cases array");
assert!(
cases.len() >= 10,
"expected the full vector set, got {}",
cases.len()
);
for case in cases {
let path = case["path"].as_str().expect("case.path is a string");
let want = case["scope"].as_str().expect("case.scope is a string");
assert_eq!(
allowlist_scope(path),
want,
"scope rule disagrees with the shared vectors for {path:?}; the JS twin \
(allowlistScope in scripts/lib/module-size-ratchet.mjs) is held to the same file"
);
}
}
fn allowlist_scope(path: &str) -> String {
let parts: Vec<&str> = path.split('/').collect();
if parts.len() >= 2 && matches!(parts[0], "packages" | "apps" | "rust") {
return format!("{}/{}", parts[0], parts[1]);
}
parts
.first()
.filter(|p| !p.is_empty())
.map_or_else(|| "other".to_string(), |p| (*p).to_string())
}
fn digest_rows(rows: &BTreeMap<String, usize>) -> u64 {
let mut lines: Vec<String> = rows.iter().map(|(p, b)| format!("{p} {b}")).collect();
lines.sort();
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in lines.join("\n").bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn allowlist_digests() -> BTreeMap<String, u64> {
let mut by_scope: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
for (path, budget) in parse_allowlist() {
by_scope
.entry(allowlist_scope(&path))
.or_default()
.insert(path, budget);
}
by_scope
.into_iter()
.map(|(scope, rows)| (scope, digest_rows(&rows)))
.collect()
}