1use crate::RealityGraph;
14use scc_core::{kinds, predicates, Archetype};
15use scc_store::Store;
16use std::collections::HashSet;
17
18const CLI_IMPORTS: &[&str] = &[
20 "clap", "cobra", "argparse", "commander", "yargs", "click", "typer", "docopt", "urfave",
21];
22
23const FRAMEWORK_IMPORTS: &[&str] = &[
25 "fastapi", "flask", "express", "nest", "nestjs", "gin", "axum", "django", "spring", "actix",
26 "aiohttp", "starlette", "falcon", "rocket", "warp", "fiber", "chi", "tornado", "mux",
27];
28
29const PHASE_VERBS: &[&str] = &[
32 "parse", "parser", "compile", "compiler", "analyze", "analyse", "analysis", "transform",
33 "generate", "generator", "lexer", "lex", "tokenize", "tokeniser", "emit", "optimize",
34 "lower", "ast",
35];
36pub fn detect_archetype(graph: &RealityGraph, store: &Store) -> Archetype {
41 let routes = graph.entities_of_kind(kinds::ROUTE).len();
43 let mut route_handlers = 0usize;
44 let mut registers = 0usize;
45 let mut config_reads = 0usize;
46 let mut injects = 0usize;
47 let mut di_entities = 0usize;
48 let mut middleware = 0usize;
49 let mut annotations = 0usize;
50 let mut exports = 0usize;
51 let mut symbols_total = 0usize;
52 let mut cli_entrypoints = 0usize;
53 let mut entrypoints_total = 0usize;
54 let mut cli_flags = 0usize;
55 let mut main_guards = 0usize;
56 let mut phase_symbols = 0usize;
57 let mut infra_files = 0usize;
58 let mut deployment_units = 0usize;
59 let mut packages = 0usize;
60 let mut cli_imports = 0usize;
61 let mut framework_imports = 0usize;
62
63 for e in graph.entities.values() {
64 match e.kind.as_str() {
65 kinds::SYMBOL => {
66 symbols_total += 1;
67 let name = e.name.to_ascii_lowercase();
68 if PHASE_VERBS.iter().any(|v| name.contains(v)) {
69 phase_symbols += 1;
70 }
71 if let Some(eps) = e.attributes.get("entrypoints").and_then(|v| v.as_array()) {
72 if !eps.is_empty() {
73 entrypoints_total += 1;
74 }
75 for k in eps {
76 match k.as_str() {
77 Some("cli-subcommand") | Some("cli") => cli_entrypoints += 1,
78 Some("main-guard") => main_guards += 1,
79 _ => {}
80 }
81 }
82 }
83 if let Some(fl) = e.attributes.get("cli_flags").and_then(|v| v.as_array()) {
84 if !fl.is_empty() {
85 cli_flags += 1;
86 }
87 }
88 }
89 kinds::ROUTE => route_handlers += graph.in_pred(&e.id, predicates::HANDLES).len(),
90 kinds::MIDDLEWARE => middleware += 1,
91 kinds::ANNOTATION => annotations += 1,
92 kinds::EXPORT => exports += 1,
93 kinds::DI_BINDING => di_entities += 1,
94 kinds::DEPLOYMENT_UNIT => deployment_units += 1,
95 kinds::PACKAGE => packages += 1,
96 kinds::FILE => {
97 let p = e.name.to_ascii_lowercase();
98 let is_infra = p.contains("dockerfile")
99 || p.contains("docker-compose")
100 || p.contains("compose.yaml")
101 || p.contains("compose.yml")
102 || p.ends_with(".tf")
103 || p.contains("/k8s")
104 || p.contains("/helm")
105 || p.contains(".github/workflows");
106 if is_infra {
107 infra_files += 1;
108 }
109 }
110 _ => {}
111 }
112 }
113 for rels in graph.out.values() {
115 for r in rels {
116 match r.predicate.as_str() {
117 predicates::REGISTERS => registers += 1,
118 predicates::CONFIGURED_BY => config_reads += 1,
119 predicates::INJECTS => injects += 1,
120 _ => {}
121 }
122 }
123 }
124 if let Ok(all) = store.all_imports() {
129 let mut seen: HashSet<String> = HashSet::new();
130 for (_path, module, _names, _line, _ty) in all {
131 let lowered = module.to_ascii_lowercase();
132 let segs: Vec<&str> = lowered
133 .split(|c: char| !c.is_ascii_alphanumeric())
134 .filter(|s| !s.is_empty())
135 .collect();
136 if CLI_IMPORTS.iter().any(|c| segs.contains(c)) && seen.insert(module.clone()) {
137 cli_imports += 1;
138 }
139 if FRAMEWORK_IMPORTS.iter().any(|f| segs.contains(f)) && seen.insert(module) {
140 framework_imports += 1;
141 }
142 }
143 }
144
145 let export_ratio = if symbols_total == 0 {
146 0.0
147 } else {
148 exports as f64 / symbols_total as f64
149 };
150
151 let mut scores: Vec<(Archetype, i32)> = Vec::new();
153 let mut push = |a: Archetype, s: i32| scores.push((a, s));
154
155 let mut s = 0;
157 if routes > 0 {
158 s += 2;
159 }
160 if route_handlers >= 3 {
161 s += 1;
162 }
163 if deployment_units > 0 {
164 s += 1;
165 }
166 if framework_imports > 0 {
167 s += 1;
168 }
169 push(Archetype::ServiceApplication, s);
170
171 s = 0;
173 if cli_entrypoints > 0 {
174 s += 3;
175 }
176 if cli_imports > 0 {
177 s += 2;
178 }
179 if cli_flags > 0 {
180 s += 2;
181 }
182 if main_guards > 0 {
183 s += 1;
184 }
185 push(Archetype::Cli, s);
186
187 s = 0;
190 if exports > 0 {
191 if export_ratio > 0.5 {
192 s += 3;
193 } else if export_ratio > 0.3 {
194 s += 2;
195 }
196 if routes == 0 {
197 s += 1;
198 }
199 if entrypoints_total == 0 && route_handlers == 0 {
200 s += 1;
201 }
202 }
203 push(Archetype::LibrarySdk, s);
204
205 s = 0;
210 if routes > 0 {
211 s += 2;
212 }
213 let framework_evidence = routes > 0 || registers > 0 || middleware > 0;
214 if framework_imports > 0 && framework_evidence {
215 s += 3;
216 } else if framework_imports > 0 {
217 s += 1;
218 }
219 if middleware > 0 {
220 s += 2;
221 }
222 if registers > 0 {
223 s += 1;
224 }
225 if config_reads > 0 {
226 s += 1;
227 }
228 push(Archetype::WebFramework, s);
229
230 s = 0;
232 if phase_symbols >= 8 {
233 s += 3;
234 } else if phase_symbols >= 3 {
235 s += 2;
236 }
237 if exports > 0 {
238 s += 1;
239 }
240 push(Archetype::CompilerLanguageTool, s);
241
242 s = 0;
244 if registers >= 5 {
245 s += 3;
246 }
247 if di_entities > 0 || injects > 0 {
248 s += 2;
249 }
250 if annotations >= 3 {
251 s += 2;
252 }
253 if middleware > 0 {
254 s += 1;
255 }
256 push(Archetype::PluginFramework, s);
257
258 s = 0;
262 if infra_files > 0 {
263 s += 3;
264 if deployment_units > 0 {
265 s += 1;
266 }
267 if symbols_total < 20 {
268 s += 1;
269 }
270 }
271 push(Archetype::InfrastructureProject, s);
272
273 s = 0;
275 if packages >= 3 {
276 s += 3;
277 } else if packages >= 2 {
278 s += 2;
279 }
280 if deployment_units >= 2 {
281 s += 2;
282 }
283 push(Archetype::MonorepoPlatform, s);
284
285 let mut best = Archetype::Unknown;
287 let mut best_score = i32::MIN;
288 for (a, sc) in scores {
289 if sc > best_score || (sc == best_score && rank(a) < rank(best)) {
290 best = a;
291 best_score = sc;
292 }
293 }
294 if best_score <= 0 {
296 return Archetype::Unknown;
297 }
298 best
299}
300
301fn rank(a: Archetype) -> usize {
303 Archetype::PRECEDENCE
304 .iter()
305 .position(|p| *p == a)
306 .unwrap_or(usize::MAX)
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum ClusterPrior {
314 CliCommands,
316 FrameworkRegistrations,
318 ServiceEntrypoints,
321 CompilerPhases,
323}
324
325pub const PRIOR_WEIGHT: i32 = 2;
327
328pub fn cluster_prior(archetype: Archetype) -> Option<ClusterPrior> {
333 match archetype {
334 Archetype::Cli => Some(ClusterPrior::CliCommands),
335 Archetype::WebFramework => Some(ClusterPrior::FrameworkRegistrations),
336 Archetype::ServiceApplication => Some(ClusterPrior::ServiceEntrypoints),
337 Archetype::CompilerLanguageTool => Some(ClusterPrior::CompilerPhases),
338 _ => None,
339 }
340}
341
342pub fn is_phase_symbol(name: &str) -> bool {
346 let n = name.to_ascii_lowercase();
347 PHASE_VERBS.iter().any(|v| n.contains(v))
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use scc_core::{entity_id, symbol_id, Entity, Provenance, Relationship};
354 use scc_store::Store;
355
356 fn detect(facts: impl FnOnce(&Store)) -> Archetype {
359 let dir = tempfile::TempDir::new().unwrap();
360 let root = dir.path().join("repo");
361 std::fs::create_dir_all(&root).unwrap();
362 let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
363 facts(&store);
364 let graph = RealityGraph::load(&store).unwrap();
365 detect_archetype(&graph, &store)
366 }
367
368 fn file(store: &Store, path: &str) -> String {
369 let id = entity_id(&store.repo_id, kinds::FILE, path);
370 store
371 .insert_entity(
372 &Entity::new(id.clone(), kinds::FILE, path),
373 &[path.into()],
374 )
375 .unwrap();
376 id
377 }
378
379 fn sym(store: &Store, path: &str, name: &str) -> String {
380 let id = symbol_id(&store.repo_id, path, name);
381 store
382 .insert_entity(&Entity::new(id.clone(), kinds::SYMBOL, name), &[path.into()])
383 .unwrap();
384 id
385 }
386
387 fn route(store: &Store, name: &str, handler: &str, sym: &str) {
388 let id = entity_id(&store.repo_id, kinds::ROUTE, name);
389 store
390 .insert_entity(
391 Entity::new(id.clone(), kinds::ROUTE, name)
392 .attr("method", serde_json::json!("GET"))
393 .attr("path", serde_json::json!("/x")),
394 &["main.py".into()],
395 )
396 .unwrap();
397 store
398 .insert_relationship(
399 &Relationship::new(
400 format!("rel:h:{name}"),
401 sym.to_string(),
402 predicates::HANDLES,
403 id,
404 Provenance::Extracted,
405 ),
406 "main.py",
407 )
408 .unwrap();
409 let _ = handler;
410 }
411
412 fn import(store: &Store, path: &str, module: &str) {
413 store
414 .insert_imports(
415 path,
416 &[(module.to_string(), Vec::new(), 1, "module".to_string())],
417 )
418 .unwrap();
419 }
420
421 fn cli_symbol(store: &Store, path: &str, name: &str, with_flags: bool) -> String {
422 let id = sym(store, path, name);
423 let mut e = store.get_entity(&id).unwrap().unwrap();
424 e.attributes.insert(
425 "entrypoints".into(),
426 serde_json::json!(["cli-subcommand"]),
427 );
428 if with_flags {
429 e.attributes.insert("cli_flags".into(), serde_json::json!(["--port"]));
430 }
431 store.insert_entity(&e, &[path.into()]).unwrap();
432 id
433 }
434
435 #[test]
436 fn empty_repo_is_unknown() {
437 let a = detect(|_| {});
438 assert_eq!(a, Archetype::Unknown);
439 }
440
441 #[test]
442 fn cli_archetype_from_subcommands_and_imports() {
443 let a = detect(|s| {
444 let f = file(s, "cli.py");
445 let _ = f;
446 cli_symbol(s, "cli.py", "serve", true);
447 cli_symbol(s, "cli.py", "deploy", true);
448 sym(s, "cli.py", "main");
449 import(s, "cli.py", "argparse");
450 import(s, "main.go", "github.com/spf13/cobra");
451 import(s, "cli.rs", "clap");
452 });
453 assert_eq!(a, Archetype::Cli, "cli signals must win");
454 }
455
456 #[test]
457 fn web_framework_beats_service_application_on_middleware() {
458 let a = detect(|s| {
459 let h1 = sym(s, "app.py", "ping");
460 let h2 = sym(s, "app.py", "get_item");
461 let h3 = sym(s, "app.py", "create_item");
462 route(s, "get-/ping", "", &h1);
463 route(s, "get-/items/{id}", "", &h2);
464 route(s, "post-/items", "", &h3);
465 sym(s, "app.py", "RequestLogger");
466 let mw = entity_id(&s.repo_id, kinds::MIDDLEWARE, "RequestLogger");
467 s.insert_entity(
468 &Entity::new(mw, kinds::MIDDLEWARE, "RequestLogger"),
469 &["app.py".into()],
470 )
471 .unwrap();
472 import(s, "app.py", "fastapi");
473 import(s, "app.py", "flask");
474 });
475 assert_eq!(a, Archetype::WebFramework, "framework+middleware must win");
476 }
477
478 #[test]
479 fn library_sdk_wins_on_high_export_ratio() {
480 let a = detect(|s| {
481 for (i, name) in ["alpha", "beta", "gamma", "delta"].iter().enumerate() {
482 let id = sym(s, "lib.rs", name);
483 let exp = entity_id(&s.repo_id, kinds::EXPORT, name);
484 s.insert_entity(
485 &Entity::new(exp.clone(), kinds::EXPORT, *name),
486 &["lib.rs".into()],
487 )
488 .unwrap();
489 s.insert_relationship(
490 &Relationship::new(
491 format!("rel:e{i}"),
492 id,
493 predicates::EXPORTS,
494 exp,
495 Provenance::Extracted,
496 ),
497 "lib.rs",
498 )
499 .unwrap();
500 }
501 sym(s, "lib.rs", "internal_helper");
502 sym(s, "lib.rs", "another_helper");
503 });
504 assert_eq!(a, Archetype::LibrarySdk, "4/6 exported must read as library");
505 }
506
507 #[test]
508 fn compiler_language_tool_from_phase_symbols() {
509 let a = detect(|s| {
510 for name in [
511 "parse", "parse_expression", "tokenize", "lexer", "compile", "compile_module",
512 "transform", "generate_code", "emit", "analyze",
513 ] {
514 sym(s, "compiler.rs", name);
515 }
516 });
517 assert_eq!(a, Archetype::CompilerLanguageTool);
518 }
519
520 #[test]
521 fn plugin_framework_from_registrations_and_di() {
522 let a = detect(|s| {
523 let owner = sym(s, "plugin.py", "register_plugin");
524 for i in 0..6 {
525 let target = entity_id(&s.repo_id, kinds::CONTRACT, &format!("plugin_{i}"));
526 s.insert_entity(
527 &Entity::new(target.clone(), kinds::CONTRACT, format!("plugin_{i}")),
528 &["plugin.py".into()],
529 )
530 .unwrap();
531 s.insert_relationship(
532 &Relationship::new(
533 format!("rel:reg{i}"),
534 owner.clone(),
535 predicates::REGISTERS,
536 target,
537 Provenance::Extracted,
538 ),
539 "plugin.py",
540 )
541 .unwrap();
542 }
543 let binding = entity_id(&s.repo_id, kinds::DI_BINDING, "svc");
544 s.insert_entity(
545 &Entity::new(binding, kinds::DI_BINDING, "svc"),
546 &["plugin.py".into()],
547 )
548 .unwrap();
549 });
550 assert_eq!(a, Archetype::PluginFramework);
551 }
552
553 #[test]
554 fn infrastructure_project_from_manifests() {
555 let a = detect(|s| {
556 file(s, "Dockerfile");
557 file(s, "docker-compose.yml");
558 file(s, "terraform/main.tf");
559 file(s, "k8s/deployment.yaml");
560 let du = entity_id(&s.repo_id, kinds::DEPLOYMENT_UNIT, "web");
561 s.insert_entity(
562 &Entity::new(du, kinds::DEPLOYMENT_UNIT, "web"),
563 &["Dockerfile".into()],
564 )
565 .unwrap();
566 });
567 assert_eq!(a, Archetype::InfrastructureProject);
568 }
569
570 #[test]
571 fn monorepo_from_workspace_packages() {
572 let a = detect(|s| {
573 for p in ["pkg_a", "pkg_b", "pkg_c"] {
574 let id = entity_id(&s.repo_id, kinds::PACKAGE, p);
575 s.insert_entity(
576 Entity::new(id, kinds::PACKAGE, p).attr("path", serde_json::json!(p)),
577 &["x".into()],
578 )
579 .unwrap();
580 }
581 for du in ["api", "web"] {
582 let id = entity_id(&s.repo_id, kinds::DEPLOYMENT_UNIT, du);
583 s.insert_entity(
584 Entity::new(id, kinds::DEPLOYMENT_UNIT, du)
585 .attr("build_context", serde_json::json!(format!("services/{du}"))),
586 &["x".into()],
587 )
588 .unwrap();
589 }
590 });
591 assert_eq!(a, Archetype::MonorepoPlatform);
592 }
593
594 #[test]
595 fn ties_break_by_fixed_precedence() {
596 let a = detect(|s| {
600 let h1 = sym(s, "app.py", "ping");
601 let h2 = sym(s, "app.py", "get_item");
602 route(s, "get-/ping", "", &h1);
603 route(s, "get-/items/{id}", "", &h2);
604 import(s, "app.py", "fastapi");
605 });
606 assert_eq!(a, Archetype::WebFramework);
610 }
611
612 #[test]
613 fn cli_beats_web_framework_when_axum_router_also_present() {
614 let a = detect(|s| {
617 let h1 = sym(s, "cli.rs", "health");
618 let h2 = sym(s, "cli.rs", "list_users");
619 route(s, "get-/health", "", &h1);
620 route(s, "get-/users", "", &h2);
621 cli_symbol(s, "cli.rs", "Cli", true);
622 cli_symbol(s, "main.go", "serveCmd", true);
623 cli_symbol(s, "cli.py", "serve", true);
624 import(s, "cli.rs", "clap");
625 import(s, "main.go", "github.com/spf13/cobra");
626 import(s, "cli.py", "argparse");
627 import(s, "cli.rs", "axum");
628 });
629 assert_eq!(a, Archetype::Cli, "cli entrypoints + 3 cli imports must dominate");
630 }
631}