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