diff --git a/.repowiseIgnore b/.repowiseIgnore index 764355b0..aea93cbc 100644 --- a/.repowiseIgnore +++ b/.repowiseIgnore @@ -23,7 +23,10 @@ crates/root_tui_main_entry.rs # module's mod.rs/lib.rs; that is intrinsic to the AES layered architecture, # not removable duplication (merging would break the 327 dependents). # Known repowise false positive — exclude from health scoring. +# Only verified-pure barrels are excluded; lib.rs carrying real logic is +# re-included below so its health is still scored. build.rs is NOT excluded +# (build scripts contain real logic). **/mod.rs **/lib.rs -**/build.rs +!crates/external-lint/src/lib.rs diff --git a/crates/shared/src/common/utility_signature_parser.rs b/crates/shared/src/common/utility_signature_parser.rs index 9f3ecb66..165f17ad 100644 --- a/crates/shared/src/common/utility_signature_parser.rs +++ b/crates/shared/src/common/utility_signature_parser.rs @@ -184,48 +184,91 @@ pub fn python_signature_uses_forbidden_primitive(sig: &str) -> Vec<&'static str> /// Collect forbidden primitive type tokens from the parameter section. fn collect_python_param_primitives(lower: &str, forbidden: &mut Vec<&'static str>) { - if lower.contains(": str") { + // Whole-token matching: `: int` fires, `: internal_id` does not. + if contains_token_prefix(lower, ": str") { forbidden.push("str"); } - if lower.contains(": int") { + if contains_token_prefix(lower, ": int") { forbidden.push("int"); } - if lower.contains(": float") { + if contains_token_prefix(lower, ": float") { forbidden.push("float"); } - // Only flag bare `list`/`dict` without type parameters (e.g., `List[ResultVO]` is OK) - if lower.contains(": list") && !lower.contains(": list[") { + // Only flag bare `list`/`dict` without type parameters. `list [ResultVO]` + // (whitespace before the bracket) is parameterized, not bare. + if contains_bare_token(lower, ": list") { forbidden.push("list"); } - if lower.contains(": dict") && !lower.contains(": dict[") { + if contains_bare_token(lower, ": dict") { forbidden.push("dict"); } } +/// True when `haystack` contains `prefix` as a whole token that is NOT +/// followed by a generic bracket (`[`, allowing whitespace between) — +/// i.e. a bare `list`/`dict`. +fn contains_bare_token(haystack: &str, prefix: &str) -> bool { + let prefix_len = prefix.len(); + haystack.match_indices(prefix).any(|(i, _)| { + let Some(rest) = haystack.get(i + prefix_len..) else { + return true; // prefix at end of string — bare + }; + let Some(next) = rest.chars().next() else { + return true; // prefix at end of string — bare + }; + // Whole-token boundary AND not a parameterized generic type + // (`list [ResultVO]` / `list[ResultVO]` are not bare). + !next.is_alphanumeric() && next != '_' && !rest.trim_start().starts_with('[') + }) +} + +/// True when `haystack` contains `prefix` followed by a non-identifier +/// character (whole-token match). Unicode-aware: the character after the +/// prefix is checked with `is_alphanumeric`. +fn contains_token_prefix(haystack: &str, prefix: &str) -> bool { + let prefix_len = prefix.len(); + haystack.match_indices(prefix).any(|(i, _)| { + haystack + .get(i + prefix_len..) + .and_then(|rest| rest.chars().next()) + .is_none_or(|c| !c.is_alphanumeric() && c != '_') + }) +} + /// Collect forbidden primitive type tokens from the return section (after `->`). fn collect_python_return_primitives(lower: &str, forbidden: &mut Vec<&'static str>) { let Some(arrow_idx) = lower.find("->") else { return; }; let ret = lower[arrow_idx + 2..].trim(); - if ret.starts_with("str") { + // Match whole tokens only: `-> int` fires, `-> IntervalVO` does not. + if ret.starts_with("str") && is_token_end(ret, 3) { forbidden.push("str"); } - if ret.starts_with("int") { + if ret.starts_with("int") && is_token_end(ret, 3) { forbidden.push("int"); } - if ret.starts_with("float") { + if ret.starts_with("float") && is_token_end(ret, 5) { forbidden.push("float"); } - // Only flag bare `list`/`dict` without type parameters - if ret.starts_with("list") && !ret.starts_with("list[") { + // Only flag bare `list`/`dict` without type parameters. Allow + // whitespace before the generic bracket (`list [ResultVO]`, `dict [K, V]`). + if ret.starts_with("list") && is_token_end(ret, 4) && !ret[4..].trim_start().starts_with('[') { forbidden.push("list"); } - if ret.starts_with("dict") && !ret.starts_with("dict[") { + if ret.starts_with("dict") && is_token_end(ret, 4) && !ret[4..].trim_start().starts_with('[') { forbidden.push("dict"); } } +/// True when the text after `prefix_len` is not an identifier character, +/// so `prefix` stands alone as a whole token. Unicode-aware. +fn is_token_end(s: &str, prefix_len: usize) -> bool { + s.get(prefix_len..) + .and_then(|rest| rest.chars().next()) + .is_none_or(|c| !c.is_alphanumeric() && c != '_') +} + /// Extract `(line_no, raw_signature_line)` for every method declaration inside a TypeScript /// `interface` or `class` that uses primitive types in parameter/return annotations. pub fn extract_typescript_method_signatures(content: &str) -> Vec<(usize, String)> { @@ -300,7 +343,9 @@ fn push_inline_ts_signature( fn brace_pair(line: &str) -> Option<(usize, usize)> { let open = line.find('{')?; let close = line.rfind('}')?; - Some((open, close)) + // Guard against a reversed range (e.g. `} ... {` on one line) so the + // caller's `&trimmed[open + 1..close]` slice never panics. + (close > open).then_some((open, close)) } /// True when the inline body of a one-line block uses primitive annotations. diff --git a/crates/shared/src/filesystem/taxonomy_filesystem_vo.rs b/crates/shared/src/filesystem/taxonomy_filesystem_vo.rs index b96d9832..8384c878 100644 --- a/crates/shared/src/filesystem/taxonomy_filesystem_vo.rs +++ b/crates/shared/src/filesystem/taxonomy_filesystem_vo.rs @@ -593,24 +593,55 @@ impl InboundLinkMap { /// Priority 6: boundary-aligned suffix matching in both directions. /// Requires the boundary to sit on a path separator so that e.g. /// `foo_vo.rs` does not match `bar_vo.rs`. + /// Picks the longest (most specific) matching key; equal-length keys are + /// broken lexicographically so results are deterministic regardless of + /// HashMap iteration order. Keys are normalized the same way the matcher + /// normalizes them, so `/src/a.rs` and `src/a.rs` score identically. fn boundary_suffix(&self, path: &str) -> Option<&Vec> { - let clean = path.strip_prefix("./").unwrap_or(path); + let clean = normalize_path(path.strip_prefix("./").unwrap_or(path)); + let mut best: Option<(&Vec, usize, String)> = None; for (k, v) in &self.mapping { - let k_clean = k.strip_prefix("./").unwrap_or(k); - if k_clean.is_empty() || clean.is_empty() { + let k_norm = normalize_path(k.strip_prefix("./").unwrap_or(k)); + if k_norm.is_empty() || clean.is_empty() { continue; } - if boundary_ends_with(k_clean, clean) || boundary_ends_with(clean, k_clean) { - return Some(v); + let matched_len = if boundary_ends_with(k_norm, clean) { + Some(k_norm.len()) + } else if boundary_ends_with(clean, k_norm) { + // Reverse direction: the matching key's length is what makes a + // candidate specific, so score by k_norm.len() here too. + Some(k_norm.len()) + } else { + None + }; + let Some(len) = matched_len else { continue }; + let better = match best { + None => true, + Some((_, best_len, ref best_key)) => { + len > best_len || (len == best_len && k_norm < best_key.as_str()) + } + }; + if better { + best = Some((v, len, k_norm.to_string())); } } - None + best.map(|(v, _, _)| v) } } +/// Strip leading `./` and `/` so equivalent path forms compare identically. +fn normalize_path(p: &str) -> &str { + p.trim_start_matches("./").trim_start_matches('/') +} + /// True when `full` ends with `suffix` and the boundary before the suffix is /// either the start of the string or a path separator (`/` or `\`). +/// Leading `./` and `/` are stripped from both sides first so a suffix like +/// `/b_vo.rs` is treated the same as `b_vo.rs` (the separator check then +/// applies to the byte just before the matched suffix). fn boundary_ends_with(full: &str, suffix: &str) -> bool { + let full = full.trim_start_matches("./").trim_start_matches('/'); + let suffix = suffix.trim_start_matches("./").trim_start_matches('/'); if !full.ends_with(suffix) { return false; } @@ -618,7 +649,7 @@ fn boundary_ends_with(full: &str, suffix: &str) -> bool { before == 0 || full .as_bytes() - .get(before) + .get(before - 1) .is_some_and(|b| *b == b'/' || *b == b'\\') } diff --git a/crates/shared/tests/unit_shared_common_vo.rs b/crates/shared/tests/unit_shared_common_vo.rs index fef8415a..4b5d6726 100644 --- a/crates/shared/tests/unit_shared_common_vo.rs +++ b/crates/shared/tests/unit_shared_common_vo.rs @@ -617,6 +617,44 @@ fn forbidden_primitive_detection_python() { assert!(clean.is_empty()); } +#[test] +fn python_generic_brackets_with_space_not_flagged_as_bare_list_dict() { + // `list [ResultVO]` / `dict [KeyVO, ValueVO]` are parameterized and must + // not be reported as bare `list` / `dict`. + let found = python_signature_uses_forbidden_primitive("def run(self) -> list [ResultVO]:"); + assert!( + !found.contains(&"list"), + "list [ResultVO] should not be bare list" + ); + + let found = + python_signature_uses_forbidden_primitive("def run(self) -> dict [KeyVO, ValueVO]:"); + assert!( + !found.contains(&"dict"), + "dict [K, V] should not be bare dict" + ); + + // Parameter-side spaced generic annotations are also not bare. + let found = + python_signature_uses_forbidden_primitive("def run(self, items: list [ResultVO]) -> bool:"); + assert!( + !found.contains(&"list"), + "param list [ResultVO] should not be bare list" + ); + + let found = python_signature_uses_forbidden_primitive( + "def run(self, mapping: dict [KeyVO, ValueVO]) -> bool:", + ); + assert!( + !found.contains(&"dict"), + "param dict [K, V] should not be bare dict" + ); + + // Bare list/dict without brackets are still flagged. + let found = python_signature_uses_forbidden_primitive("def run(self) -> list:"); + assert!(found.contains(&"list")); +} + #[test] fn forbidden_primitive_detection_typescript() { let found = typescript_signature_uses_forbidden_primitive("getName(x: string): any"); diff --git a/crates/shared/tests/unit_shared_filesystem_vo.rs b/crates/shared/tests/unit_shared_filesystem_vo.rs index 61fa81b7..227163d5 100644 --- a/crates/shared/tests/unit_shared_filesystem_vo.rs +++ b/crates/shared/tests/unit_shared_filesystem_vo.rs @@ -199,6 +199,16 @@ fn get_importers_boundary_suffix_does_not_partial_match() { assert!(map.get_importers("/b_vo.rs").is_none()); } +#[test] +fn get_importers_boundary_suffix_picks_most_specific_key() { + // When multiple keys suffix-match a query, the longest (most specific) + // key must win regardless of HashMap iteration order. + let map = map_of(&[("src/a.rs", &["specific.rs"]), ("a.rs", &["generic.rs"])]); + let result = map.get_importers("root/src/a.rs"); + assert_eq!(result.map(|v| v.len()), Some(1)); + assert_eq!(result.unwrap()[0], "specific.rs"); +} + #[test] fn get_importers_missing_returns_none() { let map = map_of(&[("a.rs", &["b.rs"])]);