Skip to main content

libverify_core/
scope.rs

1//! Scope classification and semantic connectivity logic for change request analysis.
2//!
3//! Determines whether a change request's changes are well-scoped (single logical unit)
4//! or spread across disconnected domains.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::verdict::Severity;
9
10/// Classify the scope of a change request based on the number of connected components
11/// among its changed code files.
12/// Verified by Creusot in `gh-verify-verif` crate.
13pub 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, // 3+
21    }
22}
23
24/// Known non-code file extensions that should be excluded from scope analysis.
25pub 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
30/// Known non-code path prefixes that should be excluded from scope analysis.
31pub const NON_CODE_PREFIXES: &[&str] =
32    &[".github/", "docs/", "benches/", "benchmarks/", "examples/"];
33
34/// Known non-code filenames (no extension) that are infrastructure/metadata.
35/// These are common across many OSS ecosystems and should not trigger test-coverage requirements.
36pub 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/// Coarse role of a changed file for weak semantic connectivity.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum FileRole {
63    Source,
64    Test,
65    Fixture,
66}
67
68/// Determine whether a file path refers to a non-code file.
69pub 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    // Dotfiles (e.g. .gitignore, .prettierignore) are infrastructure, not code.
77    if basename.starts_with('.') {
78        return true;
79    }
80    // Known non-code filenames (OWNERS, LICENSE, Makefile, Dockerfile, etc.)
81    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
92/// Resolve an import path against a set of changed file paths.
93/// Returns the index of the matched file, if any.
94pub fn resolve_import(import_path: &str, filenames: &[&str]) -> Option<usize> {
95    let mut path = import_path;
96
97    // Strip quotes (Go imports include them)
98    if path.len() >= 2 && (path.starts_with('"') || path.starts_with('\'')) {
99        path = &path[1..path.len() - 1];
100    }
101
102    // Strip relative prefixes
103    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    // Convert Python dotted notation to path
112    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; // suppress unused warning
119    }
120
121    // Match against changed file names (suffix match)
122    for (idx, fname) in filenames.iter().enumerate() {
123        // Exact suffix match
124        if fname.ends_with(path) {
125            return Some(idx);
126        }
127        // Try with common extensions
128        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
147/// Classify file role from path shape and filename conventions.
148pub 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
160/// Extract semantic tokens from path for weak matching.
161pub 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
175/// Source-source weak bridge used for colocated feature files.
176/// Guarded by strict long-stem overlap to avoid short-name over-merging.
177pub 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
198/// Bridge test/fixture file to a source file with semantic token overlap.
199/// Guards: source/aux balance, role check, parent_dir difference, and
200/// token overlap (≥5 chars, non-generic).
201pub 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    // Too many aux files suggests bulk cleanup, not focused change.
208    if aux_count > 6 {
209        return false;
210    }
211    // A single aux file should not absorb a broad source-only change.
212    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    // Do not collapse same-parent unit test pairs (can hide real split concerns).
226    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
235/// Bridge files by semantic overlap of changed-patch identifiers.
236///
237/// This complements call/import edges when tree-sitter cannot recover a
238/// complete AST from patch fragments, while keeping scope guards strict.
239pub 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            // Keep source-source semantic bridging narrow: only small
257            // implementation clusters that are accompanied by tests/fixtures.
258            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            // If there is only one aux file, avoid bridging when source side
268            // is broad. Otherwise allow focused source+aux semantic coupling.
269            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
281/// Bridge between test and fixture files that target the same behavior.
282pub 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
297/// Bridge build-fork variants that share one canonical feature surface.
298pub 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/// Result of feature namespace extraction.
328#[derive(Debug, Clone)]
329pub struct FeatureNamespace {
330    /// The dominant feature token.
331    pub token: String,
332    /// Indices into the input path slice for files that belong to this namespace.
333    pub member_indices: Vec<usize>,
334}
335
336/// Extract a dominant feature namespace from a set of changed file paths.
337///
338/// Returns `Some` if a non-generic, non-structural token of sufficient length
339/// appears in ≥ 35% of files across ≥ 2 directory subtrees, with ≥ 4 total files.
340/// After finding a namespace, runs one absorption round to pull in files that share
341/// any qualifying token with existing members.
342pub 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    // Count file indices and subtrees per qualifying token.
351    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    // Exclude project-level tokens that appear in almost every file (only for larger PRs).
369    let upper_bound = if n >= 10 {
370        (n as f64 * 0.9).ceil() as usize
371    } else {
372        n + 1 // effectively disabled for small PRs
373    };
374
375    // Solo pass: single token with len >= 6.
376    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    // Bigram pass: two tokens each >= 5 chars whose intersection covers threshold.
405    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
455/// One-round absorption: pull non-member files into the namespace if they share
456/// a qualifying token with any *original* member. Uses only the token set from
457/// the initial members to prevent transitive over-expansion.
458fn absorb_related_files(ns: &mut FeatureNamespace, all_tokens: &[Vec<String>], n: usize) {
459    // Collect qualifying tokens from original members.
460    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
481/// Check whether a token is a generic name OR a common directory-convention name
482/// that should not serve as a feature namespace anchor.
483fn 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    // Directory-based markers (both nested and top-level)
534    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        // Filename-based markers
545        || 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        // Exact filename matches for test modules (Rust `tests.rs`, `test.rs`)
552        || 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
614/// Detect the package root by finding the prefix before the first conventional
615/// boundary directory (src, lib, test, tests, __tests__, e2e).
616/// Falls back to parent_dir when no boundary is found.
617fn package_root(path: &str) -> &str {
618    // Handle repo-root boundaries (e.g. "src/...", "test/...")
619    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]; // "src", "test", etc.
623        }
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        // Top-level test directories (Express.js, Mocha, etc.)
814        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        // RSpec spec/ directory (top-level and nested)
823        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        // Top-level fixtures
833        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        // Same-dir unit test must NOT bridge
854        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        // Same package, different dirs, token overlap → bridge
862        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        // Scoped packages in monorepo → bridge
870        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        // Same package (compiler), src/ vs test/ → bridge
878        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        // Cross-package with token overlap → bridge allowed
886        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        // Too many aux files → bulk operation, no bridge
894        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        // Realistic stencil CR paths (dotfiles already filtered by is_non_code_file)
997        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        // "loader" is the dominant token bridging implementation and test harness
1020        assert_eq!(ns.token, "loader", "token={}", ns.token);
1021        // After absorption, all files should be covered (they all share tokens
1022        // like "loader", "custom", "elements", or "stencil" with the core cluster)
1023        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        // Different domains: auth, billing, docs — no shared feature token
1033        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        // All files under same tree root "src/feature" → no namespace bridge
1062        assert!(extract_feature_namespace(paths).is_none());
1063    }
1064
1065    #[test]
1066    fn feature_namespace_rejects_generic_tokens() {
1067        // Token "compiler" is structural, "test" is generic
1068        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        // "custom" (6 chars) appears in all files across 3 subtrees → solo match
1080        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        // "alpha" (5) + "bravo" (5) co-occur — neither qualifies solo (< 6 chars)
1096        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        // "frobnicator" appears in only 2/7 files (29% < 35%)
1115        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")); // via is_generic_token
1130        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;