Skip to main content

sniff/
roles.rs

1use crate::llm::{LLMClient, ResponseSchema};
2use crate::types::FileRecord;
3use std::collections::HashMap;
4#[cfg(test)]
5use std::sync::Mutex;
6use std::sync::{Arc, OnceLock, RwLock};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum FileRole {
10    Entrypoint,
11    Script,
12    Example,
13    Fixture,
14    Test,
15    Docs,
16    Generated,
17    AdapterIntegration,
18    Library,
19    Mixed,
20}
21
22#[path = "utility_names.rs"]
23mod names;
24
25#[path = "roles_heuristics.rs"]
26mod heuristics;
27#[path = "roles_paths.rs"]
28mod paths;
29#[path = "roles_python_names.rs"]
30mod python_names;
31#[path = "roles_surface.rs"]
32mod surface;
33
34#[cfg(test)]
35pub(crate) static ROLE_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
36
37pub use heuristics::heuristic_file_role;
38pub use names::is_utility_helper_name;
39pub use paths::{
40    contains_any, file_name, is_adapter_integration_path, is_cli_path, is_docs_path,
41    is_entrypoint_path, is_example_path, is_fixture_path, is_generated_path, is_script_path,
42    is_test_path, normalize_path, starts_with_segment,
43};
44pub use python_names::{is_python_alias_export_assignment, is_python_identifier_like};
45pub use surface::{
46    is_callback_contract_module, is_config_validation_module, is_detector_facade_module,
47    is_hydration_hook_module, is_module_barrel_module, is_presentation_surface_module,
48    is_protocol_stub_method, is_protocol_surface_module, is_pure_reexport_module,
49    is_thin_delegation_method, is_thin_wrapper_export, is_wrapper_assignment,
50    is_wrapper_only_module, source_looks_like_wrapper_module,
51};
52
53pub fn is_intentional_surface_path(file_path: &str) -> bool {
54    let normalized = normalize_path(file_path);
55    let name = file_name(&normalized);
56
57    is_entrypoint_path(&normalized, name)
58        || is_script_path(&normalized)
59        || is_example_path(&normalized)
60        || is_fixture_path(&normalized)
61        || is_test_path(&normalized, name)
62        || is_docs_path(&normalized)
63        || is_generated_path(&normalized, name)
64}
65
66pub fn is_detector_support_module(file_path: &str) -> bool {
67    let normalized = normalize_path(file_path);
68    matches!(
69        normalized.as_str(),
70        "src/roles.rs"
71            | "src/roles_paths.rs"
72            | "src/roles_python_names.rs"
73            | "src/roles_heuristics.rs"
74            | "src/roles_surface.rs"
75            | "src/roles_surface_presentation.rs"
76            | "src/roles_surface_assignment.rs"
77            | "src/roles_surface_facade.rs"
78            | "src/roles_surface_protocol.rs"
79            | "src/utility_names.rs"
80            | "src/analyzer_support.rs"
81            | "src/analyzer_file_verdicts.rs"
82            | "src/analyzer_verdicts_detector.rs"
83            | "src/analyzer_verdicts_clear.rs"
84            | "src/analyzer_verdicts_rules.rs"
85            | "src/analyzer_verdicts_rules_analysis.rs"
86            | "src/file_verdicts_builder.rs"
87            | "src/scorer_file.rs"
88            | "src/scorer_method.rs"
89            | "src/reporter_console.rs"
90            | "src/parser_impl.rs"
91            | "src/signal_layers_similarity_roles.rs"
92    ) || normalized.ends_with("/src/roles.rs")
93        || normalized.ends_with("/src/roles_paths.rs")
94        || normalized.ends_with("/src/roles_python_names.rs")
95        || normalized.ends_with("/src/roles_heuristics.rs")
96        || normalized.ends_with("/src/roles_surface.rs")
97        || normalized.ends_with("/src/roles_surface_presentation.rs")
98        || normalized.ends_with("/src/roles_surface_assignment.rs")
99        || normalized.ends_with("/src/roles_surface_facade.rs")
100        || normalized.ends_with("/src/roles_surface_protocol.rs")
101        || normalized.ends_with("/src/utility_names.rs")
102        || normalized.ends_with("/analyzer_support.rs")
103        || normalized.ends_with("/analyzer_file_verdicts.rs")
104        || normalized.ends_with("/analyzer_verdicts_detector.rs")
105        || normalized.ends_with("/analyzer_verdicts_clear.rs")
106        || normalized.ends_with("/analyzer_verdicts_rules.rs")
107        || normalized.ends_with("/analyzer_verdicts_rules_analysis.rs")
108        || normalized.ends_with("/file_verdicts_builder.rs")
109        || normalized.ends_with("/scorer_file.rs")
110        || normalized.ends_with("/scorer_method.rs")
111        || normalized.ends_with("/reporter_console.rs")
112        || normalized.ends_with("/parser_impl.rs")
113        || normalized.ends_with("/signal_layers_similarity_roles.rs")
114}
115
116fn truncate_source(source: &str, max_chars: usize) -> String {
117    if source.chars().count() <= max_chars {
118        return source.to_string();
119    }
120
121    let head = max_chars / 2;
122    let tail = max_chars - head;
123    let head_text: String = source.chars().take(head).collect();
124    let tail_text: String = source
125        .chars()
126        .rev()
127        .take(tail)
128        .collect::<Vec<_>>()
129        .into_iter()
130        .rev()
131        .collect();
132    format!("{head_text}\n...\n{tail_text}")
133}
134
135fn parse_role(value: &serde_json::Value) -> FileRole {
136    let role = value
137        .get("role")
138        .and_then(|v| v.as_str())
139        .unwrap_or("")
140        .trim()
141        .to_lowercase();
142
143    match role.as_str() {
144        "cli_entrypoint" | "entrypoint" => FileRole::Entrypoint,
145        "adapter_integration" => FileRole::AdapterIntegration,
146        "example" => FileRole::Example,
147        "fixture" => FileRole::Fixture,
148        "test" => FileRole::Test,
149        "docs" => FileRole::Docs,
150        "generated" => FileRole::Generated,
151        "core_library" | "library" => FileRole::Library,
152        "mixed" => FileRole::Mixed,
153        _ => FileRole::Mixed,
154    }
155}
156
157async fn classify_with_llm(
158    client: &LLMClient,
159    file: &FileRecord,
160) -> Result<(FileRole, usize, usize), String> {
161    let prompt = format!(
162        "You classify codebase file roles for Sniff.\n\
163Choose exactly one role from: core_library, adapter_integration, cli_entrypoint, example, test, docs, generated, mixed.\n\
164- core_library: reusable product logic\n\
165- adapter_integration: glue code that adapts between the core and an external system or framework\n\
166- cli_entrypoint: main functions and command entrypoints\n\
167- example: sample or demo code\n\
168- test: test code\n\
169- docs: documentation\n\
170- generated: generated code\n\
171- mixed: the file mixes concerns or is too ambiguous to place cleanly\n\n\
172File path: {}\n\
173Language: {}\n\
174\n\
175Source:\n\
176---\n\
177{}\n\
178---\n\n\
179Return only JSON:\n\
180{{\n\
181  \"role\": \"core_library | adapter_integration | cli_entrypoint | example | test | docs | generated | mixed\",\n\
182  \"reason\": \"short plain-English explanation\"\n\
183}}",
184        file.file_path,
185        file.language,
186        truncate_source(&file.source, 2800)
187    );
188
189    let (result, in_tok, out_tok) = client
190        .call(&prompt, ResponseSchema::RoleClassification)
191        .await?;
192    let role = result.as_ref().map(parse_role).unwrap_or(FileRole::Mixed);
193
194    Ok((role, in_tok, out_tok))
195}
196
197pub async fn resolve_file_roles(
198    file_records: &[FileRecord],
199    client: Arc<LLMClient>,
200) -> Result<(usize, usize), String> {
201    let mut input_tokens = 0usize;
202    let mut output_tokens = 0usize;
203    let mut unresolved = Vec::new();
204
205    for file in file_records {
206        if let Some(role) = heuristic_file_role(&file.file_path) {
207            cache_file_role(&file.file_path, role);
208            continue;
209        }
210
211        unresolved.push(file.clone());
212    }
213
214    if unresolved.is_empty() {
215        return Ok((input_tokens, output_tokens));
216    }
217
218    for file in unresolved {
219        match classify_with_llm(client.as_ref(), &file).await {
220            Ok((role, in_tok, out_tok)) => {
221                let file_path = file.file_path.clone();
222                cache_file_role(&file_path, role);
223                input_tokens += in_tok;
224                output_tokens += out_tok;
225            }
226            Err(err) => {
227                return Err(format!(
228                    "Role resolution failed for {}: {}",
229                    file.file_path, err
230                ));
231            }
232        }
233    }
234
235    Ok((input_tokens, output_tokens))
236}
237
238fn cache_key(file_path: &str) -> String {
239    normalize_path(file_path)
240}
241
242fn role_cache() -> &'static RwLock<HashMap<String, FileRole>> {
243    static ROLE_CACHE: OnceLock<RwLock<HashMap<String, FileRole>>> = OnceLock::new();
244    ROLE_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
245}
246
247pub fn clear_file_role_cache() {
248    if let Ok(mut cache) = role_cache().write() {
249        cache.clear();
250    }
251}
252
253pub fn cache_file_role(file_path: &str, role: FileRole) {
254    if let Ok(mut cache) = role_cache().write() {
255        cache.insert(cache_key(file_path), role);
256    }
257}
258
259fn cached_file_role(file_path: &str) -> Option<FileRole> {
260    role_cache()
261        .read()
262        .ok()
263        .and_then(|cache| cache.get(&cache_key(file_path)).copied())
264}
265
266pub fn classify_file_role(file_path: &str) -> FileRole {
267    if let Some(role) = cached_file_role(file_path) {
268        return role;
269    }
270
271    heuristic_file_role(file_path).unwrap_or(FileRole::Mixed)
272}
273
274pub fn file_role_label(role: FileRole) -> &'static str {
275    match role {
276        FileRole::Entrypoint => "cli_entrypoint",
277        FileRole::Script => "cli_entrypoint",
278        FileRole::Example => "example",
279        FileRole::Fixture => "example",
280        FileRole::Test => "test",
281        FileRole::Docs => "docs",
282        FileRole::Generated => "generated",
283        FileRole::AdapterIntegration => "adapter_integration",
284        FileRole::Library => "core_library",
285        FileRole::Mixed => "mixed",
286    }
287}
288
289pub fn is_intentional_surface(file_path: &str) -> bool {
290    if is_intentional_surface_path(file_path) {
291        return true;
292    }
293
294    matches!(
295        classify_file_role(file_path),
296        FileRole::Entrypoint
297            | FileRole::Script
298            | FileRole::Example
299            | FileRole::Fixture
300            | FileRole::Test
301            | FileRole::Docs
302            | FileRole::Generated
303    )
304}
305
306pub fn is_intentional_surface_record(file: &FileRecord) -> bool {
307    is_intentional_surface(&file.file_path)
308        || is_compatibility_shim_record(file)
309        || is_analysis_reexports_module(&file.file_path)
310        || is_analysis_findings_facade_module(&file.file_path)
311        || is_analysis_finding_workspace_module(&file.file_path)
312        || is_support_boundary_module(file)
313        || is_data_catalog_module(file)
314        || is_contract_type_module(file)
315        || is_support_plumbing_module(&file.file_path)
316        || is_provider_facade_module(file)
317        || is_utility_surface_module(file)
318        || is_protocol_surface_module(file)
319        || is_presentation_surface_module(file)
320        || is_hydration_hook_module(file)
321        || is_callback_contract_module(file)
322        || is_config_validation_module(file)
323        || is_analysis_evidence_module(file)
324        || is_module_barrel_module(file)
325}
326
327pub fn is_utility_surface_module(file: &FileRecord) -> bool {
328    if file.methods.len() < 3 {
329        return false;
330    }
331
332    let normalized = normalize_path(&file.file_path);
333    let stem = file_name(&normalized)
334        .split('.')
335        .next()
336        .unwrap_or("")
337        .to_lowercase();
338    let path_is_utility_like = normalized.contains("/utils/")
339        || normalized.contains("/common/")
340        || normalized.contains("/shared/")
341        || normalized.ends_with("/utils.ts")
342        || normalized.ends_with("/utils.js")
343        || normalized.ends_with("/utils.py");
344    let stem_is_generic = matches!(
345        stem.as_str(),
346        "helpers" | "helper" | "utils" | "common" | "shared"
347    );
348    let stem_looks_like_domain_rules = stem.contains("rules");
349    let method_names_are_utility_like = file
350        .methods
351        .iter()
352        .all(|method| is_utility_helper_name(&method.name));
353
354    if stem_looks_like_domain_rules && !stem_is_generic {
355        return false;
356    }
357
358    (path_is_utility_like && stem_is_generic) || method_names_are_utility_like
359}
360
361pub fn is_compatibility_shim_path(file_path: &str) -> bool {
362    let normalized = normalize_path(file_path);
363    normalized == "src/llm.py" || normalized.ends_with("/src/llm.py")
364}
365
366pub fn is_compatibility_shim_record(file: &FileRecord) -> bool {
367    is_compatibility_shim_path(&file.file_path) || source_looks_like_compatibility_shim(file)
368}
369
370fn source_looks_like_compatibility_shim(file: &FileRecord) -> bool {
371    if !file.methods.is_empty() {
372        return false;
373    }
374
375    let lower = file.source.to_lowercase();
376    if !lower.contains("compatibility shim") {
377        return false;
378    }
379
380    file.source.lines().all(|line| {
381        let trimmed = line.trim();
382        trimmed.is_empty()
383            || trimmed.starts_with("//")
384            || trimmed.starts_with("/*")
385            || trimmed.starts_with('*')
386            || trimmed.starts_with("@file:")
387            || trimmed.starts_with("package ")
388            || trimmed.starts_with("import ")
389            || trimmed.starts_with("#")
390    })
391}
392
393pub fn is_support_boundary_module(file: &FileRecord) -> bool {
394    let normalized = normalize_path(&file.file_path);
395    let name = file_name(&normalized);
396
397    let is_explicit_boundary = matches!(
398        name,
399        "service-worker-support.ts"
400            | "feature-flags.ts"
401            | "password-session-cache.ts"
402            | "service-worker-support.js"
403            | "service-worker-support.py"
404            | "service-worker-support.rs"
405            | "service-worker-support.kt"
406    );
407
408    let looks_like_support_boundary = is_explicit_boundary
409        || (normalized.contains("/src/lib/")
410            && (name.ends_with("-support.ts")
411                || name.ends_with("-flags.ts")
412                || name.ends_with("-cache.ts")))
413        || name.ends_with("-support.js")
414        || name.ends_with("-support.py")
415        || name.ends_with("-support.rs")
416        || name.ends_with("-support.kt");
417
418    if !looks_like_support_boundary {
419        return false;
420    }
421
422    if !is_explicit_boundary && file.methods.len() > 8 {
423        return false;
424    }
425
426    normalized.contains("/background/core/")
427        || normalized.contains("/src/lib/")
428        || normalized.contains("/lib/")
429        || normalized.contains("/core/")
430        || normalized.contains("/support/")
431}
432
433pub fn is_data_catalog_module(file: &FileRecord) -> bool {
434    let normalized = normalize_path(&file.file_path);
435    if !normalized.contains("/src/data/") {
436        return false;
437    }
438
439    if file.methods.len() < 3 {
440        return false;
441    }
442
443    let source = file.source.to_lowercase();
444    let looks_like_catalog =
445        source.contains("record<string,") || source.contains("object.fromentries(");
446    let has_exported_surface = source.contains("export const ")
447        || source.contains("export function ")
448        || source.contains("export interface ")
449        || source.contains("export type ");
450
451    looks_like_catalog && has_exported_surface
452}
453
454pub fn is_contract_type_module(file: &FileRecord) -> bool {
455    if !file.methods.is_empty() {
456        return false;
457    }
458
459    let normalized = normalize_path(&file.file_path);
460    let name = file_name(&normalized);
461    let looks_like_contract_path = normalized.contains("/contracts/")
462        || name.ends_with("-contracts.ts")
463        || name.ends_with("contracts.ts")
464        || name.ends_with(".types.ts")
465        || name.ends_with("_types.ts");
466    if !looks_like_contract_path {
467        return false;
468    }
469
470    let source = file.source.to_lowercase();
471    let has_type_declarations = source.contains("export type ")
472        || source.contains("export interface ")
473        || source.contains("interface ")
474        || source.contains("type ");
475    let has_runtime_code = source.contains("function ")
476        || source.contains("class ")
477        || source.contains("=> {")
478        || source.contains("async ")
479        || source.contains("return ");
480
481    has_type_declarations && !has_runtime_code
482}
483
484pub fn is_analysis_finding_helper_module(file_path: &str) -> bool {
485    let normalized = normalize_path(file_path);
486    let name = file_name(&normalized);
487    normalized.contains("/analysis/")
488        && name.starts_with("finding_")
489        && name.ends_with(".py")
490        && !name.contains("signatures")
491}
492
493pub fn is_analysis_finding_support_module(file_path: &str) -> bool {
494    let normalized = normalize_path(file_path);
495    let name = file_name(&normalized);
496    normalized.contains("/analysis/")
497        && name.starts_with("finding_")
498        && name.ends_with(".py")
499        && (name.ends_with("_base.py")
500            || name.ends_with("_compat.py")
501            || name == "finding_diff.py"
502            || name.ends_with("_public_names.py")
503            || name.ends_with("_all_contract.py")
504            || name.ends_with("_packaging.py")
505            || name.ends_with("_workspace.py")
506            || name == "finding_workspace.py")
507        && !name.contains("signatures")
508        && !name.contains("findings")
509        && !name.contains("detection_context")
510}
511
512pub fn is_analysis_finding_workspace_module(file_path: &str) -> bool {
513    let normalized = normalize_path(file_path);
514    normalized.ends_with("/analysis/finding_workspace.py")
515}
516
517pub fn is_analysis_findings_facade_module(file_path: &str) -> bool {
518    let normalized = normalize_path(file_path);
519    normalized.ends_with("/analysis/findings.py")
520}
521
522pub fn is_analysis_explanation_support_module(file_path: &str) -> bool {
523    let normalized = normalize_path(file_path);
524    normalized.ends_with("/analysis/explanation_facts.py")
525}
526
527pub fn is_analysis_reexports_module(file_path: &str) -> bool {
528    let normalized = normalize_path(file_path);
529    let name = file_name(&normalized);
530    normalized.contains("/analysis/") && name.ends_with("_reexports.py")
531}
532
533pub fn is_analysis_evidence_module(file: &FileRecord) -> bool {
534    let normalized = normalize_path(&file.file_path);
535    normalized.ends_with("/analysis/evidence.py")
536        || normalized.ends_with("/analysis/evidence.ts")
537        || normalized.ends_with("/analysis/evidence.kt")
538        || normalized.ends_with("/analysis/evidence.rs")
539}
540
541pub fn is_support_plumbing_module(file_path: &str) -> bool {
542    let normalized = normalize_path(file_path);
543    if is_smart_filler_support_module(&normalized)
544        || is_session_state_contract_module(&normalized)
545        || is_user_safe_support_module(&normalized)
546        || is_core_planning_support_module(&normalized)
547        || is_appstore_row_helper_module(&normalized)
548        || is_design_tokens_module(&normalized)
549        || is_appstore_hydration_module(&normalized)
550        || is_host_support_surface_module(&normalized)
551        || is_shared_contract_surface_module(&normalized)
552    {
553        return true;
554    }
555    normalized.ends_with("/analysis/diff_text.py")
556        || normalized.ends_with("/analysis/finding_diff.py")
557        || normalized.ends_with("/release/candidate.py")
558        || normalized.ends_with("/eval/fixtures.py")
559        || normalized.ends_with("/analyzer_support.rs")
560        || normalized.ends_with("/analyzer_verdicts_rules_analysis.rs")
561        || normalized.ends_with("/file_verdicts_builder.rs")
562        || normalized.ends_with("/parser_impl.rs")
563        || normalized.contains("/parser_impl/")
564        || normalized.ends_with("/signal_layers_similarity_roles.rs")
565        || normalized.ends_with("/analyzer.rs")
566        || normalized.ends_with("/scorer_name_utils.rs")
567        || normalized.ends_with("/symbol_graph_resolver.rs")
568        || normalized.ends_with("/walker.rs")
569        || normalized.ends_with("/analyzer_verdicts_rules.rs")
570        || normalized.ends_with("/orchestrator/explanation_facts.py")
571        || normalized.ends_with("/orchestrator/base_classification.py")
572        || normalized.ends_with("/orchestrator/analysis_stage.py")
573        || normalized.ends_with("/orchestrator/explainability.py")
574        || normalized.ends_with("/orchestrator/explanation_polish.py")
575        || normalized.ends_with("/orchestrator/court_output.py")
576        || normalized.ends_with("/orchestrator/court_payload.py")
577        || normalized.ends_with("/orchestrator/court_setup.py")
578        || normalized.ends_with("/orchestrator/adjudication.py")
579        || normalized.ends_with("/orchestrator/scope.py")
580        || normalized.ends_with("/config.py")
581        || normalized.ends_with("/planner.py")
582        || normalized.ends_with("/versioning/tags.py")
583        || normalized.ends_with("/prompt_pack.py")
584        || normalized.ends_with("/licensing/policy.py")
585        || normalized.ends_with("/io/tokens.py")
586        || normalized.ends_with("/policies/guards.py")
587        || normalized.ends_with("/integrations/github/guards.py")
588        || normalized.ends_with("/integrations/github/events.py")
589        || normalized.ends_with("/integrations/github/server.py")
590        || normalized.ends_with("/integrations/github/runtime.py")
591        || normalized.ends_with("/integrations/github/persistence_postgres_publish_decision_ops.py")
592        || normalized.ends_with("/integrations/github/persistence_serialization.py")
593        || normalized.ends_with("/integrations/github/persistence_ephemeral.py")
594        || normalized.ends_with("/providers/llm_transport.py")
595        || normalized.ends_with("/providers/llm_payloads.py")
596        || normalized.ends_with("/retry.py")
597        || normalized.ends_with("/providers/llm.py")
598        || normalized.ends_with("/llm_json.rs")
599        || normalized == "src/analyzer_verdicts_rules.rs"
600}
601
602fn is_host_support_surface_module(normalized: &str) -> bool {
603    let name = file_name(normalized)
604        .split('.')
605        .next()
606        .unwrap_or("")
607        .to_lowercase();
608    let host_context = normalized.contains("/host/")
609        || normalized.contains("/androidhost/")
610        || normalized.contains("/reminder-host-jvm/")
611        || normalized.contains("/services/");
612    let supportish = name.contains("support")
613        || name.ends_with("adapters")
614        || name.ends_with("models")
615        || name.ends_with("localization")
616        || name.ends_with("callbacks")
617        || (name.ends_with("runtime") && name.contains("host"));
618
619    host_context && supportish
620}
621
622fn is_shared_contract_surface_module(normalized: &str) -> bool {
623    let name = file_name(normalized)
624        .split('.')
625        .next()
626        .unwrap_or("")
627        .to_lowercase();
628    let shared_context = normalized.contains("/shared/core/")
629        || normalized.contains("/shared/contract/")
630        || normalized.contains("/shared/uicontract/");
631    let surfaceish = name.ends_with("contract")
632        || name.ends_with("contracts")
633        || name.ends_with("protocol")
634        || name.ends_with("protocols")
635        || name.ends_with("model")
636        || name.ends_with("models")
637        || name.ends_with("hydration")
638        || name.ends_with("shadowruntimehandlers")
639        || name.ends_with("callbacks")
640        || name.ends_with("facade");
641
642    shared_context && surfaceish
643}
644
645pub fn is_provider_facade_module(file: &FileRecord) -> bool {
646    is_provider_facade_path(&file.file_path)
647        && file.methods.len() >= 8
648        && is_provider_facade_shape(file)
649}
650
651pub fn is_provider_facade_path(file_path: &str) -> bool {
652    let normalized = normalize_path(file_path);
653    normalized.ends_with("/providers/llm.py")
654}
655
656fn is_provider_facade_shape(file: &FileRecord) -> bool {
657    let lowered = file.source.to_lowercase();
658    let import_count = lowered.matches("from bumpkin.providers").count();
659    let wrapper_count = file
660        .methods
661        .iter()
662        .filter(|method| {
663            let name = method.name.as_str();
664            name.starts_with('_')
665                || name.starts_with("get_")
666                || name.starts_with("validate_")
667                || name.starts_with("normalize_")
668        })
669        .count();
670
671    import_count >= 4 && wrapper_count >= 4
672}
673
674fn is_smart_filler_support_module(normalized: &str) -> bool {
675    if !normalized.contains("/smart-filler/") {
676        return false;
677    }
678
679    matches!(
680        file_name(normalized),
681        "heuristics.ts" | "dom-utils.ts" | "smart-filler-lib.ts"
682    ) || normalized.ends_with("/smart-filler/heuristics.ts")
683        || normalized.ends_with("/smart-filler/dom-utils.ts")
684        || normalized.ends_with("/smart-filler-lib.ts")
685}
686
687fn is_session_state_contract_module(normalized: &str) -> bool {
688    normalized.ends_with("/session/session.types.ts")
689        || normalized.ends_with("/session.types.ts")
690        || normalized.contains("/session/") && file_name(normalized).ends_with(".types.ts")
691}
692
693fn is_user_safe_support_module(normalized: &str) -> bool {
694    let name = file_name(normalized);
695    normalized.contains("ui/src/lib/") && (name.starts_with("user-safe-") || name == "logo-drag.ts")
696}
697
698fn is_appstore_hydration_module(normalized: &str) -> bool {
699    let name = file_name(normalized)
700        .split('.')
701        .next()
702        .unwrap_or("")
703        .to_lowercase();
704    normalized.contains("/shared/core/appstore/") && name.ends_with("hydration")
705}
706
707fn is_core_planning_support_module(normalized: &str) -> bool {
708    let name = file_name(normalized)
709        .split('.')
710        .next()
711        .unwrap_or("")
712        .to_lowercase();
713    normalized.contains("/shared/core/") && name.ends_with("planner")
714}
715
716fn is_appstore_row_helper_module(normalized: &str) -> bool {
717    let name = file_name(normalized)
718        .split('.')
719        .next()
720        .unwrap_or("")
721        .to_lowercase();
722    normalized.contains("/shared/core/appstore/") && name.ends_with("cabinetrows")
723}
724
725fn is_design_tokens_module(normalized: &str) -> bool {
726    let name = file_name(normalized)
727        .split('.')
728        .next()
729        .unwrap_or("")
730        .to_lowercase();
731    normalized.contains("/design/") && name.ends_with("tokens")
732}
733
734pub fn is_parsing_or_serialization_helper_module(file_path: &str) -> bool {
735    let normalized = normalize_path(file_path);
736    let name = file_name(&normalized);
737    name.ends_with("_parsing.py")
738        || name.ends_with("_serialization.py")
739        || name.ends_with("_serialization_helpers.py")
740        || normalized.ends_with("/analysis/diff_text.py")
741        || normalized.ends_with("/release/candidate.py")
742}
743
744pub fn is_versioning_tag_module(file_path: &str) -> bool {
745    let normalized = normalize_path(file_path);
746    normalized.ends_with("/versioning/tags.py")
747}
748
749#[cfg(test)]
750mod intentional_surface_path_tests {
751    use super::is_intentional_surface_path;
752
753    #[test]
754    fn path_based_intentional_surfaces_are_always_skipped() {
755        assert!(is_intentional_surface_path("tests/test_callgraph.py"));
756        assert!(is_intentional_surface_path(
757            "tests/fixtures/clean/well_structured.py"
758        ));
759        assert!(is_intentional_surface_path("scripts/run_app_server.py"));
760        assert!(is_intentional_surface_path("src/main.tsx"));
761    }
762}
763
764#[cfg(test)]
765#[path = "tests/roles.rs"]
766mod tests;