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;