diff --git a/xtask/src/component_audit/source_metrics.rs b/xtask/src/component_audit/source_metrics.rs index b8431be..7fbd8ad 100644 --- a/xtask/src/component_audit/source_metrics.rs +++ b/xtask/src/component_audit/source_metrics.rs @@ -242,12 +242,12 @@ fn underscore_self_param(signature: &str) -> Option { fn function_body_uses_param(lines: &[&str], signature_ix: usize, param: &str) -> bool { let mut depth = brace_delta(lines[signature_ix]); let after_open = lines[signature_ix].split_once('{').map(|(_, body)| body); - if after_open.is_some_and(|body| body.contains(param)) { + if after_open.is_some_and(|body| contains_identifier(body, param)) { return true; } for line in lines.iter().skip(signature_ix + 1) { - if line.contains(param) { + if contains_identifier(line, param) { return true; } depth += brace_delta(line); @@ -259,6 +259,18 @@ fn function_body_uses_param(lines: &[&str], signature_ix: usize, param: &str) -> false } +/// Return whether `text` mentions `ident` as a whole identifier, so `_id` is not found inside +/// `with_identity`: a match counts only when neither neighbouring character is `_` or ASCII +/// alphanumeric. +fn contains_identifier(text: &str, ident: &str) -> bool { + let is_ident_char = |ch: char| ch == '_' || ch.is_ascii_alphanumeric(); + text.match_indices(ident).any(|(start, _)| { + let before = text[..start].chars().next_back(); + let after = text[start + ident.len()..].chars().next(); + !before.is_some_and(is_ident_char) && !after.is_some_and(is_ident_char) + }) +} + /// Return the net curly-brace depth change contributed by one source line. fn brace_delta(line: &str) -> i32 { line.chars().fold(0, |depth, ch| match ch { diff --git a/xtask/src/component_audit/source_metrics/tests.rs b/xtask/src/component_audit/source_metrics/tests.rs index 18ebbea..98d0886 100644 --- a/xtask/src/component_audit/source_metrics/tests.rs +++ b/xtask/src/component_audit/source_metrics/tests.rs @@ -36,6 +36,18 @@ fn function_body_usage_distinguishes_real_noop_from_used_arg() { assert!(!function_body_uses_param(&unused, 0, "_id")); } +/// Catches matching a builder parameter as a substring, which hides a no-op builder whose body +/// mentions a longer identifier that starts with the parameter's name. +#[test] +fn function_body_usage_matches_whole_identifiers_only() { + let unused = [ + "pub fn id(self, _id: usize) -> Self {", + " self.with_identity()", + "}", + ]; + assert!(!function_body_uses_param(&unused, 0, "_id")); +} + /// Catches `source_metrics.rs:scan_raw_hex_in_moon` exempting token sources by file name instead /// of by path, which would let any component module named `primitives.rs` paint raw hex without /// moving the `raw_hex_in_moon_runtime` metric, or dropping the exemption, which fails the audit