1use std::collections::{HashMap, HashSet};
7
8use crate::verdict::Severity;
9
10pub fn classify_scope(code_files_count: usize, components: usize) -> Severity {
14 if code_files_count <= 1 {
15 return Severity::Pass;
16 }
17 match components {
18 0 | 1 => Severity::Pass,
19 2 => Severity::Warning,
20 _ => Severity::Error, }
22}
23
24pub const NON_CODE_EXTENSIONS: &[&str] = &[
26 ".md", ".rst", ".txt", ".adoc", ".json", ".yaml", ".yml", ".toml", ".lock", ".env", ".cfg",
27 ".ini", ".css", ".scss", ".svg", ".png", ".jpg", ".gif", ".ico", ".woff", ".woff2",
28];
29
30pub const NON_CODE_PREFIXES: &[&str] =
32 &[".github/", "docs/", "benches/", "benchmarks/", "examples/"];
33
34pub const NON_CODE_FILENAMES: &[&str] = &[
37 "OWNERS",
38 "OWNERS_ALIASES",
39 "CODEOWNERS",
40 "LICENSE",
41 "LICENCE",
42 "AUTHORS",
43 "CONTRIBUTORS",
44 "CHANGELOG",
45 "CHANGES",
46 "NOTICE",
47 "PATENTS",
48 "Makefile",
49 "Dockerfile",
50 "Vagrantfile",
51 "Procfile",
52 "Gemfile",
53 "Rakefile",
54 "Justfile",
55 "Earthfile",
56 "Tiltfile",
57 "Brewfile",
58];
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum FileRole {
63 Source,
64 Test,
65 Fixture,
66}
67
68pub fn is_non_code_file(filename: &str) -> bool {
70 for prefix in NON_CODE_PREFIXES {
71 if filename.starts_with(prefix) {
72 return true;
73 }
74 }
75 let basename = filename.rsplit('/').next().unwrap_or(filename);
76 if basename.starts_with('.') {
78 return true;
79 }
80 if NON_CODE_FILENAMES.contains(&basename) {
82 return true;
83 }
84 for ext in NON_CODE_EXTENSIONS {
85 if filename.ends_with(ext) {
86 return true;
87 }
88 }
89 false
90}
91
92pub fn resolve_import(import_path: &str, filenames: &[&str]) -> Option<usize> {
95 let mut path = import_path;
96
97 if path.len() >= 2 && (path.starts_with('"') || path.starts_with('\'')) {
99 path = &path[1..path.len() - 1];
100 }
101
102 if let Some(stripped) = path.strip_prefix("./") {
104 path = stripped;
105 } else if let Some(stripped) = path.strip_prefix("../") {
106 path = stripped;
107 } else if let Some(stripped) = path.strip_prefix("@/") {
108 path = stripped;
109 }
110
111 let converted: String;
113 if path.contains('.') && !path.contains('/') {
114 converted = path.replace('.', "/");
115 path = &converted;
116 } else {
117 converted = String::new();
118 let _ = &converted; }
120
121 for (idx, fname) in filenames.iter().enumerate() {
123 if fname.ends_with(path) {
125 return Some(idx);
126 }
127 for ext in &[
129 ".ts",
130 ".tsx",
131 ".js",
132 ".jsx",
133 ".py",
134 ".go",
135 "/index.ts",
136 "/index.js",
137 ] {
138 let with_ext = format!("{path}{ext}");
139 if fname.ends_with(&with_ext) {
140 return Some(idx);
141 }
142 }
143 }
144 None
145}
146
147pub fn classify_file_role(path: &str) -> FileRole {
149 let normalized = path.to_ascii_lowercase();
150
151 if has_fixture_marker(&normalized) {
152 return FileRole::Fixture;
153 }
154 if has_test_marker(&normalized) {
155 return FileRole::Test;
156 }
157 FileRole::Source
158}
159
160pub fn semantic_path_tokens(path: &str) -> Vec<String> {
162 let mut out = Vec::new();
163
164 for segment in path.split('/') {
165 for dot_part in segment.split('.') {
166 extend_split_tokens(dot_part, &mut out);
167 }
168 }
169
170 out.sort();
171 out.dedup();
172 out
173}
174
175pub fn should_bridge_colocated_sources(path_a: &str, path_b: &str) -> bool {
178 if classify_file_role(path_a) != FileRole::Source
179 || classify_file_role(path_b) != FileRole::Source
180 {
181 return false;
182 }
183 if parent_dir(path_a) != parent_dir(path_b) {
184 return false;
185 }
186
187 let stem_a = normalized_file_stem(path_a);
188 let stem_b = normalized_file_stem(path_b);
189 if common_prefix_len(&stem_a, &stem_b) >= 8 {
190 return true;
191 }
192
193 let tokens_a = filename_tokens(path_a);
194 let tokens_b = filename_tokens(path_b);
195 has_token_overlap(&tokens_a, &tokens_b, 8, true)
196}
197
198pub fn should_bridge_aux_to_source(
202 source_path: &str,
203 aux_path: &str,
204 source_count: usize,
205 aux_count: usize,
206) -> bool {
207 if aux_count > 6 {
209 return false;
210 }
211 if aux_count == 1 && source_count > 2 {
213 return false;
214 }
215
216 if classify_file_role(source_path) != FileRole::Source {
217 return false;
218 }
219
220 let aux_role = classify_file_role(aux_path);
221 if aux_role != FileRole::Test && aux_role != FileRole::Fixture {
222 return false;
223 }
224
225 if parent_dir(source_path) == parent_dir(aux_path) {
227 return false;
228 }
229
230 let source_tokens = semantic_path_tokens(source_path);
231 let aux_tokens = semantic_path_tokens(aux_path);
232 has_token_overlap(&source_tokens, &aux_tokens, 5, true)
233}
234
235pub fn should_bridge_patch_semantic_tokens(
240 path_a: &str,
241 path_b: &str,
242 tokens_a: &[String],
243 tokens_b: &[String],
244 source_count: usize,
245 aux_count: usize,
246) -> bool {
247 if !has_token_overlap(tokens_a, tokens_b, 6, true) {
248 return false;
249 }
250
251 let role_a = classify_file_role(path_a);
252 let role_b = classify_file_role(path_b);
253
254 match (role_a, role_b) {
255 (FileRole::Source, FileRole::Source) => {
256 aux_count > 0 && source_count <= 3 && package_root(path_a) == package_root(path_b)
259 }
260 (FileRole::Source, FileRole::Test)
261 | (FileRole::Source, FileRole::Fixture)
262 | (FileRole::Test, FileRole::Source)
263 | (FileRole::Fixture, FileRole::Source) => {
264 if parent_dir(path_a) == parent_dir(path_b) {
265 return false;
266 }
267 aux_count > 0
270 && aux_count <= 4
271 && source_count <= 3
272 && (source_count <= 2 || aux_count >= 2)
273 }
274 (FileRole::Test, FileRole::Fixture) | (FileRole::Fixture, FileRole::Test) => {
275 aux_count > 0 && aux_count <= 6
276 }
277 _ => false,
278 }
279}
280
281pub fn should_bridge_test_fixture_pair(path_a: &str, path_b: &str) -> bool {
283 let role_a = classify_file_role(path_a);
284 let role_b = classify_file_role(path_b);
285 let is_test_fixture = (role_a == FileRole::Test && role_b == FileRole::Fixture)
286 || (role_a == FileRole::Fixture && role_b == FileRole::Test);
287
288 if !is_test_fixture {
289 return false;
290 }
291
292 let tokens_a = filename_tokens(path_a);
293 let tokens_b = filename_tokens(path_b);
294 has_token_overlap(&tokens_a, &tokens_b, 5, true)
295}
296
297pub fn should_bridge_fork_variants(path_a: &str, path_b: &str) -> bool {
299 if classify_file_role(path_a) != FileRole::Source
300 || classify_file_role(path_b) != FileRole::Source
301 {
302 return false;
303 }
304
305 if !is_fork_variant_path(path_a) && !is_fork_variant_path(path_b) {
306 return false;
307 }
308
309 let family_a = fork_family_root(path_a);
310 let family_b = fork_family_root(path_b);
311 if family_a.is_empty() || family_a != family_b {
312 return false;
313 }
314
315 let stem_a = normalized_file_stem(path_a);
316 let stem_b = normalized_file_stem(path_b);
317 if stem_a != stem_b {
318 return false;
319 }
320 if stem_a.len() < 8 || is_generic_token(&stem_a) {
321 return false;
322 }
323
324 true
325}
326
327#[derive(Debug, Clone)]
329pub struct FeatureNamespace {
330 pub token: String,
332 pub member_indices: Vec<usize>,
334}
335
336pub fn extract_feature_namespace(paths: &[&str]) -> Option<FeatureNamespace> {
343 let n = paths.len();
344 if n < 4 {
345 return None;
346 }
347
348 let all_tokens: Vec<Vec<String>> = paths.iter().map(|p| semantic_path_tokens(p)).collect();
349
350 let mut token_stats: HashMap<&str, (Vec<usize>, HashSet<&str>)> = HashMap::new();
352
353 for (i, tokens) in all_tokens.iter().enumerate() {
354 let subtree = package_root(paths[i]);
355 for tok in tokens {
356 if tok.len() < 5 || is_structural_token(tok) {
357 continue;
358 }
359 let entry = token_stats.entry(tok.as_str()).or_default();
360 if entry.0.last() != Some(&i) {
361 entry.0.push(i);
362 }
363 entry.1.insert(subtree);
364 }
365 }
366
367 let threshold = (n as f64 * 0.35).ceil() as usize;
368 let upper_bound = if n >= 10 {
370 (n as f64 * 0.9).ceil() as usize
371 } else {
372 n + 1 };
374
375 let mut best: Option<(&str, &Vec<usize>)> = None;
377 let mut best_count: usize = 0;
378
379 for (token, (indices, subtrees)) in &token_stats {
380 if token.len() < 6
381 || subtrees.len() < 2
382 || indices.len() < threshold
383 || indices.len() >= upper_bound
384 {
385 continue;
386 }
387 if indices.len() > best_count
388 || (indices.len() == best_count && best.is_none_or(|(t, _)| *token < t))
389 {
390 best_count = indices.len();
391 best = Some((token, indices));
392 }
393 }
394
395 if let Some((token, indices)) = best {
396 let mut ns = FeatureNamespace {
397 token: token.to_string(),
398 member_indices: indices.clone(),
399 };
400 absorb_related_files(&mut ns, &all_tokens, n);
401 return Some(ns);
402 }
403
404 let mut short_keys: Vec<&str> = token_stats
406 .keys()
407 .filter(|t| {
408 t.len() >= 5 && {
409 let (indices, subtrees) = &token_stats[**t];
410 subtrees.len() >= 2 && indices.len() >= threshold && indices.len() < upper_bound
411 }
412 })
413 .copied()
414 .collect();
415 short_keys.sort_unstable();
416
417 let mut best_bigram: Option<(&str, Vec<usize>)> = None;
418 let mut best_bigram_count: usize = 0;
419
420 for i in 0..short_keys.len() {
421 for j in (i + 1)..short_keys.len() {
422 let set_a = &token_stats[short_keys[i]].0;
423 let set_b = &token_stats[short_keys[j]].0;
424 let intersection: Vec<usize> = set_a
425 .iter()
426 .filter(|idx| set_b.contains(idx))
427 .copied()
428 .collect();
429 if intersection.len() >= threshold
430 && (intersection.len() > best_bigram_count
431 || (intersection.len() == best_bigram_count
432 && best_bigram.as_ref().is_none_or(|(t, _)| short_keys[i] < *t)))
433 {
434 best_bigram_count = intersection.len();
435 let label = if short_keys[i].len() >= short_keys[j].len() {
436 short_keys[i]
437 } else {
438 short_keys[j]
439 };
440 best_bigram = Some((label, intersection));
441 }
442 }
443 }
444
445 best_bigram.map(|(token, member_indices)| {
446 let mut ns = FeatureNamespace {
447 token: token.to_string(),
448 member_indices,
449 };
450 absorb_related_files(&mut ns, &all_tokens, n);
451 ns
452 })
453}
454
455fn absorb_related_files(ns: &mut FeatureNamespace, all_tokens: &[Vec<String>], n: usize) {
459 let member_tokens: HashSet<&str> = ns
461 .member_indices
462 .iter()
463 .flat_map(|&i| all_tokens[i].iter())
464 .filter(|t| t.len() >= 5 && !is_structural_token(t))
465 .map(|t| t.as_str())
466 .collect();
467
468 for (i, tokens) in all_tokens.iter().enumerate().take(n) {
469 if ns.member_indices.contains(&i) {
470 continue;
471 }
472 let shares_token = tokens
473 .iter()
474 .any(|t| t.len() >= 5 && !is_structural_token(t) && member_tokens.contains(t.as_str()));
475 if shares_token {
476 ns.member_indices.push(i);
477 }
478 }
479}
480
481fn is_structural_token(token: &str) -> bool {
484 is_generic_token(token)
485 || matches!(
486 token,
487 "components"
488 | "internal"
489 | "modules"
490 | "output"
491 | "targets"
492 | "config"
493 | "build"
494 | "public"
495 | "common"
496 | "shared"
497 | "vendor"
498 | "helpers"
499 | "middleware"
500 | "handlers"
501 | "services"
502 | "models"
503 | "views"
504 | "controllers"
505 | "server"
506 | "client"
507 | "scripts"
508 | "tools"
509 | "plugin"
510 | "plugins"
511 | "providers"
512 | "resolvers"
513 | "adapters"
514 | "errors"
515 | "generated"
516 | "schemas"
517 | "routes"
518 )
519}
520
521fn has_fixture_marker(path: &str) -> bool {
522 path.contains("/__fixtures__/")
523 || path.contains("/fixtures/")
524 || path.contains("/fixture/")
525 || path.contains("/fixtures-")
526 || path.starts_with("__fixtures__/")
527 || path.starts_with("fixtures/")
528 || path.starts_with("fixture/")
529 || (path.contains("/cases/") && (path.contains("test") || path.contains("e2e")))
530}
531
532fn has_test_marker(path: &str) -> bool {
533 path.contains("/__tests__/")
535 || path.contains("/tests/")
536 || path.contains("/test/")
537 || path.contains("/spec/")
538 || path.contains("/e2e/")
539 || path.starts_with("__tests__/")
540 || path.starts_with("tests/")
541 || path.starts_with("test/")
542 || path.starts_with("spec/")
543 || path.starts_with("e2e/")
544 || path.contains(".test.")
546 || path.contains("_test.")
547 || path.contains(".spec.")
548 || path.contains("-test.")
549 || path.contains("-spec.")
550 || path.contains("test-d.ts")
551 || path.ends_with("/tests.rs")
553 || path.ends_with("/test.rs")
554 || path.ends_with("_tests.rs")
555 || path.ends_with("_spec.rb")
556}
557
558fn extend_split_tokens(input: &str, out: &mut Vec<String>) {
559 let mut buf = String::new();
560 let mut prev_is_lower = false;
561
562 for ch in input.chars() {
563 if ch.is_ascii_alphanumeric() {
564 let is_upper = ch.is_ascii_uppercase();
565 if is_upper && prev_is_lower && !buf.is_empty() {
566 push_token(&buf, out);
567 buf.clear();
568 }
569 buf.push(ch.to_ascii_lowercase());
570 prev_is_lower = ch.is_ascii_lowercase();
571 } else {
572 if !buf.is_empty() {
573 push_token(&buf, out);
574 buf.clear();
575 }
576 prev_is_lower = false;
577 }
578 }
579
580 if !buf.is_empty() {
581 push_token(&buf, out);
582 }
583}
584
585fn push_token(token: &str, out: &mut Vec<String>) {
586 if token.len() >= 3 {
587 out.push(token.to_string());
588 }
589}
590
591fn normalized_file_stem(path: &str) -> String {
592 let file = path.rsplit('/').next().unwrap_or(path);
593 let stem = file.split('.').next().unwrap_or(file);
594 stem.chars()
595 .filter(|c| c.is_ascii_alphanumeric())
596 .flat_map(char::to_lowercase)
597 .collect::<String>()
598}
599
600fn filename_tokens(path: &str) -> Vec<String> {
601 let file = path.rsplit('/').next().unwrap_or(path);
602 let stem = file.split('.').next().unwrap_or(file);
603 let mut out = Vec::new();
604 extend_split_tokens(stem, &mut out);
605 out.sort();
606 out.dedup();
607 out
608}
609
610fn parent_dir(path: &str) -> &str {
611 path.rsplit_once('/').map(|(p, _)| p).unwrap_or("")
612}
613
614fn package_root(path: &str) -> &str {
618 const ROOT_BOUNDARIES: &[&str] = &["src/", "lib/", "test/", "tests/", "__tests__/", "e2e/"];
620 for boundary in ROOT_BOUNDARIES {
621 if path.starts_with(boundary) {
622 return &path[..boundary.len() - 1]; }
624 }
625
626 const BOUNDARIES: &[&str] = &[
627 "/src/",
628 "/lib/",
629 "/test/",
630 "/tests/",
631 "/__tests__/",
632 "/e2e/",
633 ];
634 for boundary in BOUNDARIES {
635 if let Some(idx) = path.find(boundary) {
636 return &path[..idx];
637 }
638 }
639 parent_dir(path)
640}
641
642fn is_fork_variant_path(path: &str) -> bool {
643 path.contains("/forks/")
644}
645
646fn fork_family_root(path: &str) -> String {
647 if let Some((prefix, _)) = path.split_once("/forks/") {
648 return prefix.to_string();
649 }
650 parent_dir(path).to_string()
651}
652
653fn common_prefix_len(a: &str, b: &str) -> usize {
654 a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count()
655}
656
657fn has_token_overlap(
658 tokens_a: &[String],
659 tokens_b: &[String],
660 min_len: usize,
661 require_non_generic: bool,
662) -> bool {
663 tokens_a.iter().any(|a| {
664 if a.len() < min_len {
665 return false;
666 }
667 if require_non_generic && is_generic_token(a) {
668 return false;
669 }
670 tokens_b.iter().any(|b| b == a)
671 })
672}
673
674fn is_generic_token(token: &str) -> bool {
675 matches!(
676 token,
677 "test"
678 | "tests"
679 | "spec"
680 | "fixture"
681 | "fixtures"
682 | "runtime"
683 | "source"
684 | "types"
685 | "type"
686 | "index"
687 | "core"
688 | "src"
689 | "lib"
690 | "util"
691 | "utils"
692 | "package"
693 | "packages"
694 | "private"
695 | "compiler"
696 )
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702
703 #[test]
704 fn zero_or_one_file_is_pass() {
705 assert_eq!(classify_scope(0, 0), Severity::Pass);
706 assert_eq!(classify_scope(1, 1), Severity::Pass);
707 assert_eq!(classify_scope(1, 5), Severity::Pass);
708 }
709
710 #[test]
711 fn single_component_is_pass() {
712 assert_eq!(classify_scope(5, 1), Severity::Pass);
713 }
714
715 #[test]
716 fn two_components_is_warning() {
717 assert_eq!(classify_scope(5, 2), Severity::Warning);
718 }
719
720 #[test]
721 fn three_or_more_components_is_error() {
722 assert_eq!(classify_scope(5, 3), Severity::Error);
723 assert_eq!(classify_scope(10, 7), Severity::Error);
724 }
725
726 #[test]
727 fn markdown_is_non_code() {
728 assert!(is_non_code_file("README.md"));
729 assert!(is_non_code_file("docs/guide.md"));
730 }
731
732 #[test]
733 fn github_dir_is_non_code() {
734 assert!(is_non_code_file(".github/workflows/ci.yml"));
735 }
736
737 #[test]
738 fn dotfiles_are_non_code() {
739 assert!(is_non_code_file(".gitignore"));
740 assert!(is_non_code_file(".prettierignore"));
741 assert!(is_non_code_file("test/wdio/.gitignore"));
742 }
743
744 #[test]
745 fn source_files_are_code() {
746 assert!(!is_non_code_file("src/main.rs"));
747 assert!(!is_non_code_file("lib/utils.ts"));
748 assert!(!is_non_code_file("app.py"));
749 }
750
751 #[test]
752 fn resolve_relative_import() {
753 let files = vec!["src/utils/helper.ts"];
754 assert_eq!(resolve_import("./helper", &files), Some(0));
755 }
756
757 #[test]
758 fn resolve_python_dotted() {
759 let files = vec!["src/foo/bar.py"];
760 assert_eq!(resolve_import("foo.bar", &files), Some(0));
761 }
762
763 #[test]
764 fn resolve_go_quoted() {
765 let files = vec!["internal/handler.go"];
766 assert_eq!(resolve_import("\"internal/handler\"", &files), Some(0));
767 }
768
769 #[test]
770 fn no_match_returns_none() {
771 let files = vec!["src/main.rs"];
772 assert_eq!(resolve_import("nonexistent", &files), None);
773 }
774
775 #[test]
776 fn classify_scope_exhaustive_for_small_inputs() {
777 for files in 0..=10 {
778 for comps in 0..=10 {
779 let result = classify_scope(files, comps);
780 if files <= 1 {
781 assert_eq!(result, Severity::Pass, "files={files}, comps={comps}");
782 } else {
783 match comps {
784 0 | 1 => assert_eq!(result, Severity::Pass, "files={files}, comps={comps}"),
785 2 => {
786 assert_eq!(result, Severity::Warning, "files={files}, comps={comps}")
787 }
788 _ => assert_eq!(result, Severity::Error, "files={files}, comps={comps}"),
789 }
790 }
791 }
792 }
793 }
794
795 #[test]
796 fn classify_file_roles() {
797 assert_eq!(
798 classify_file_role("packages/runtime-core/src/foo.ts"),
799 FileRole::Source
800 );
801 assert_eq!(
802 classify_file_role("packages/runtime-core/__tests__/foo.spec.ts"),
803 FileRole::Test
804 );
805 assert_eq!(
806 classify_file_role("packages/runtime-core/__tests__/fixtures/foo.ts"),
807 FileRole::Fixture
808 );
809 assert_eq!(
810 classify_file_role("packages-private/vapor-e2e-test/transition/cases/mode/sample.vue"),
811 FileRole::Fixture
812 );
813 assert_eq!(classify_file_role("test/req.query.js"), FileRole::Test);
815 assert_eq!(classify_file_role("test/app.use.js"), FileRole::Test);
816 assert_eq!(classify_file_role("tests/unit/foo.py"), FileRole::Test);
817 assert_eq!(classify_file_role("e2e/login.spec.ts"), FileRole::Test);
818 assert_eq!(
819 classify_file_role("__tests__/component.spec.tsx"),
820 FileRole::Test
821 );
822 assert_eq!(classify_file_role("spec/parser_spec.rb"), FileRole::Test);
824 assert_eq!(
825 classify_file_role("spec/models/user_spec.rb"),
826 FileRole::Test
827 );
828 assert_eq!(
829 classify_file_role("gems/mylib/spec/mylib_spec.rb"),
830 FileRole::Test
831 );
832 assert_eq!(
834 classify_file_role("fixtures/sample.json"),
835 FileRole::Fixture
836 );
837 }
838
839 #[test]
840 fn colocated_source_bridge_requires_long_stem() {
841 assert!(should_bridge_colocated_sources(
842 "packages/devtools/src/ContextMenu.tsx",
843 "packages/devtools/src/ContextMenuItem.tsx"
844 ));
845 assert!(!should_bridge_colocated_sources(
846 "packages/prisma/src/auth.ts",
847 "packages/prisma/src/auth-client.ts"
848 ));
849 }
850
851 #[test]
852 fn aux_bridge_with_token_overlap() {
853 assert!(!should_bridge_aux_to_source(
855 "packages/client/src/mariadb.ts",
856 "packages/client/src/mariadb.test.ts",
857 1,
858 1,
859 ));
860
861 assert!(should_bridge_aux_to_source(
863 "packages/compiler-vapor/src/generators/expression.ts",
864 "packages/compiler-vapor/__tests__/transforms/expression.spec.ts",
865 1,
866 1,
867 ));
868
869 assert!(should_bridge_aux_to_source(
871 "packages/@ember/-internals/glimmer/lib/components/link-to.ts",
872 "packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js",
873 1,
874 2,
875 ));
876
877 assert!(should_bridge_aux_to_source(
879 "packages/compiler/src/ml_parser/parser.ts",
880 "packages/compiler/test/ml_parser/html_parser_spec.ts",
881 1,
882 1,
883 ));
884
885 assert!(should_bridge_aux_to_source(
887 "packages/runtime-core/src/apiDefineComponent.ts",
888 "packages-private/dts-test/defineComponent.test-d.ts",
889 1,
890 1,
891 ));
892
893 assert!(!should_bridge_aux_to_source(
895 "packages/compiler/src/parser.ts",
896 "packages/compiler/test/parser.spec.ts",
897 1,
898 7,
899 ));
900 }
901
902 #[test]
903 fn aux_bridge_rejects_single_aux_for_broad_source_cluster() {
904 assert!(!should_bridge_aux_to_source(
905 "packages/cli/src/Studio.ts",
906 "packages/cli/src/__tests__/Studio.vitest.ts",
907 3,
908 1,
909 ));
910 }
911
912 #[test]
913 fn patch_semantic_bridge_connects_cross_package_source_test() {
914 let source_tokens = vec!["undefined".to_string(), "setinssrsetupstate".to_string()];
915 let test_tokens = vec!["undefined".to_string(), "withasynccontext".to_string()];
916 assert!(should_bridge_patch_semantic_tokens(
917 "packages/@glimmer/reference/lib/iterable.ts",
918 "packages/ember-template-compiler/tests/plugins/assert-array-test.js",
919 &source_tokens,
920 &test_tokens,
921 1,
922 1,
923 ));
924 }
925
926 #[test]
927 fn patch_semantic_bridge_connects_small_source_cluster_with_aux() {
928 let a = vec!["setinssrsetupstate".to_string()];
929 let b = vec!["setinssrsetupstate".to_string()];
930 assert!(should_bridge_patch_semantic_tokens(
931 "packages/runtime-core/src/component.ts",
932 "packages/runtime-core/src/apiSetupHelpers.ts",
933 &a,
934 &b,
935 2,
936 1,
937 ));
938 }
939
940 #[test]
941 fn patch_semantic_bridge_rejects_large_source_cluster_with_single_aux() {
942 let source = vec!["studio".to_string(), "userfacingerror".to_string()];
943 let aux = vec!["studio".to_string(), "userfacingerror".to_string()];
944 assert!(!should_bridge_patch_semantic_tokens(
945 "packages/cli/src/Studio.ts",
946 "packages/cli/src/__tests__/Studio.vitest.ts",
947 &source,
948 &aux,
949 3,
950 1,
951 ));
952 }
953
954 #[test]
955 fn test_fixture_bridge_uses_semantic_overlap() {
956 assert!(should_bridge_test_fixture_pair(
957 "packages/vue/__tests__/transition.spec.ts",
958 "packages/vue/__tests__/fixtures/transition.html"
959 ));
960 assert!(!should_bridge_test_fixture_pair(
961 "packages/vue/__tests__/alpha.spec.ts",
962 "packages/vue/__tests__/fixtures/beta.html"
963 ));
964 }
965
966 #[test]
967 fn fork_variant_bridge_requires_same_family_and_stem() {
968 assert!(should_bridge_fork_variants(
969 "packages/shared/ReactFeatureFlags.js",
970 "packages/shared/forks/ReactFeatureFlags.native-oss.js"
971 ));
972 assert!(should_bridge_fork_variants(
973 "packages/shared/forks/ReactFeatureFlags.test-renderer.js",
974 "packages/shared/forks/ReactFeatureFlags.test-renderer.www.js"
975 ));
976 }
977
978 #[test]
979 fn fork_variant_bridge_rejects_broad_over_merge() {
980 assert!(!should_bridge_fork_variants(
981 "packages/shared/index.js",
982 "packages/shared/forks/index.www.js"
983 ));
984 assert!(!should_bridge_fork_variants(
985 "packages/shared/ReactFeatureFlags.js",
986 "packages/other/forks/ReactFeatureFlags.native-oss.js"
987 ));
988 assert!(!should_bridge_fork_variants(
989 "packages/shared/ReactFeatureFlags.js",
990 "packages/shared/ReactFeatureFlags.native-oss.js"
991 ));
992 }
993
994 #[test]
995 fn feature_namespace_fires_on_single_feature_rollout() {
996 let paths = &[
998 "src/compiler/config/outputs/validate-custom-element.ts",
999 "src/compiler/config/test/validate-output-dist-custom-element.spec.ts",
1000 "src/compiler/output-targets/dist-custom-elements/custom-elements-types.ts",
1001 "src/compiler/output-targets/dist-custom-elements/generate-loader-module.ts",
1002 "src/compiler/output-targets/dist-custom-elements/index.ts",
1003 "src/compiler/output-targets/test/output-targets-dist-custom-elements.spec.ts",
1004 "src/declarations/stencil-public-compiler.ts",
1005 "test/bundle-size/stencil.config.ts",
1006 "test/wdio/auto-loader.stencil.config.ts",
1007 "test/wdio/auto-loader/auto-loader-child.tsx",
1008 "test/wdio/auto-loader/auto-loader-dynamic.tsx",
1009 "test/wdio/auto-loader/auto-loader-root.tsx",
1010 "test/wdio/auto-loader/cmp.test.tsx",
1011 "test/wdio/auto-loader/components.d.ts",
1012 "test/wdio/auto-loader/perf-dist.test.tsx",
1013 "test/wdio/auto-loader/perf.test.tsx",
1014 "test/wdio/stencil.config.ts",
1015 ];
1016 let ns = extract_feature_namespace(paths);
1017 assert!(ns.is_some(), "should detect feature namespace");
1018 let ns = ns.unwrap();
1019 assert_eq!(ns.token, "loader", "token={}", ns.token);
1021 assert!(
1024 ns.member_indices.len() >= 15,
1025 "expected ≥15 members after absorption, got {}",
1026 ns.member_indices.len()
1027 );
1028 }
1029
1030 #[test]
1031 fn feature_namespace_rejects_multi_domain_pr() {
1032 let paths = &[
1034 "packages/auth/src/login.ts",
1035 "packages/billing/src/invoice.ts",
1036 "packages/docs/src/api-reference.ts",
1037 "packages/ci/scripts/deploy.sh",
1038 ];
1039 assert!(extract_feature_namespace(paths).is_none());
1040 }
1041
1042 #[test]
1043 fn feature_namespace_rejects_fewer_than_4_files() {
1044 let paths = &[
1045 "src/dist-custom-elements/index.ts",
1046 "test/dist-custom-elements/test.ts",
1047 "lib/dist-custom-elements/util.ts",
1048 ];
1049 assert!(extract_feature_namespace(paths).is_none());
1050 }
1051
1052 #[test]
1053 fn feature_namespace_rejects_single_subtree() {
1054 let paths = &[
1055 "src/feature/frobnicator-impl.ts",
1056 "src/feature/frobnicator-types.ts",
1057 "src/feature/frobnicator-config.ts",
1058 "src/feature/frobnicator-utils.ts",
1059 "src/feature/frobnicator-extra.ts",
1060 ];
1061 assert!(extract_feature_namespace(paths).is_none());
1063 }
1064
1065 #[test]
1066 fn feature_namespace_rejects_generic_tokens() {
1067 let paths = &[
1069 "packages/compiler/alpha.ts",
1070 "tests/compiler/alpha.spec.ts",
1071 "lib/compiler/beta.ts",
1072 "tools/compiler/gamma.ts",
1073 ];
1074 assert!(extract_feature_namespace(paths).is_none());
1075 }
1076
1077 #[test]
1078 fn feature_namespace_solo_fires_on_6char_token() {
1079 let paths = &[
1081 "src/components/custom-modal/index.ts",
1082 "src/components/custom-modal/styles.ts",
1083 "test/e2e/custom-modal/basic.spec.ts",
1084 "test/e2e/custom-modal/advanced.spec.ts",
1085 "docs-app/custom-modal/demo.tsx",
1086 ];
1087 let ns = extract_feature_namespace(paths);
1088 assert!(ns.is_some(), "solo should fire for 'custom'");
1089 let ns = ns.unwrap();
1090 assert_eq!(ns.token, "custom", "token={}", ns.token);
1091 }
1092
1093 #[test]
1094 fn feature_namespace_bigram_fires_on_short_token_pair() {
1095 let paths = &[
1097 "src/alpha-bravo/index.ts",
1098 "src/alpha-bravo/types.ts",
1099 "test/alpha-bravo/basic.spec.ts",
1100 "lib/alpha-bravo/util.ts",
1101 ];
1102 let ns = extract_feature_namespace(paths);
1103 assert!(ns.is_some(), "bigram should fire for alpha+bravo");
1104 let ns = ns.unwrap();
1105 assert!(
1106 ns.token == "alpha" || ns.token == "bravo",
1107 "token={}",
1108 ns.token
1109 );
1110 }
1111
1112 #[test]
1113 fn feature_namespace_below_coverage_threshold() {
1114 let paths = &[
1116 "src/core/frobnicator.ts",
1117 "test/frobnicator.spec.ts",
1118 "src/auth/login.ts",
1119 "src/billing/invoice.ts",
1120 "lib/config/settings.ts",
1121 "pkg/analytics/tracker.ts",
1122 "tools/deployment/deploy.ts",
1123 ];
1124 assert!(extract_feature_namespace(paths).is_none());
1125 }
1126
1127 #[test]
1128 fn is_structural_token_covers_directory_conventions() {
1129 assert!(is_structural_token("compiler")); assert!(is_structural_token("components"));
1131 assert!(is_structural_token("config"));
1132 assert!(is_structural_token("test"));
1133 assert!(is_structural_token("utils"));
1134 assert!(!is_structural_token("autoloader"));
1135 assert!(!is_structural_token("frobnicator"));
1136 assert!(!is_structural_token("elements"));
1137 }
1138}
1139
1140#[cfg(test)]
1141#[path = "tests/scope_hardening.rs"]
1142mod scope_hardening;