Skip to main content

sniff/
roles.rs

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;