Skip to main content

scc_graph/
archetype.rs

1//! Archetype detection (Ontology phase — the COMPILER-gap attack).
2//!
3//! Deterministic evidence scoring over the reality graph + raw import
4//! table classifies a repository into one of eight archetypes (plus the
5//! honest `Unknown` fallback). Every signal contributes +1..+3; the
6//! highest total wins; score ties are broken by the fixed precedence
7//! order in [`scc_core::Archetype::PRECEDENCE`].
8//!
9//! Provenance discipline: all signals are counts/shape facts over
10//! EXTRACTED entities and relationships — no heuristics are promoted to
11//! facts, this function only classifies.
12
13use crate::RealityGraph;
14use scc_core::{kinds, predicates, Archetype};
15use scc_store::Store;
16use std::collections::HashSet;
17
18/// CLI framework import segment names (module paths split on `/.`-`_`).
19const CLI_IMPORTS: &[&str] = &[
20    "clap", "cobra", "argparse", "commander", "yargs", "click", "typer", "docopt", "urfave",
21];
22
23/// Web-framework import segment names.
24const 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
29/// Compiler/tool phase verbs: symbols whose name contains one of these are
30/// `parse/analyze/transform/generate`-style phase symbols.
31const 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];
36// trace:v1 id=impl.scc.archetype work=WORK-SCC-005 satisfies=REQ-SCC-IR
37
38/// Deterministic evidence scores per archetype. Pure function of graph +
39/// import-table shape; every signal is an integer count comparison.
40pub fn detect_archetype(graph: &RealityGraph, store: &Store) -> Archetype {
41    // ---- raw signals (deterministic; sorted iteration everywhere) ----
42    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    // relationship counts (deterministic totals)
114    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    // raw import modules (store table: includes unresolved externals that
125    // never became EXTERNAL_API entities) — segment-exact matching so
126    // `click` never matches `clickhouse` and `github.com/spf13/cobra`
127    // matches `cobra`.
128    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    // ---- scoring (+1..+3 per signal; highest total wins) ----
152    let mut scores: Vec<(Archetype, i32)> = Vec::new();
153    let mut push = |a: Archetype, s: i32| scores.push((a, s));
154
155    // ServiceApplication: routes + deployment units, no lib-scale exports
156    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    // Cli: subcommand entrypoints + clap/cobra/argparse imports + flags
172    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    // LibrarySdk: exported-symbol ratio, few/no routes or entrypoints.
188    // Zero exports (empty or infra-only repos) never scores library.
189    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    // WebFramework: routes + framework registrations + middleware. The
206    // framework-import bonus is strong (+3) only when accompanied by
207    // route/registration/middleware evidence — a route-less repo that merely
208    // imports a framework somewhere (docs/tests) is not a web framework.
209    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    // CompilerLanguageTool: parse/analyze/transform/generate symbols
231    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    // PluginFramework: registrations/DI/annotations dominating
243    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    // InfrastructureProject: manifests + deployment units, few symbols.
259    // The gate is manifest presence — a bare empty repo with few symbols
260    // is never "infrastructure".
261    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    // MonorepoPlatform: workspace packages + multiple deployment units
274    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    // ---- deterministic winner: max score, precedence order on ties ----
286    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    // scores are sums of positive signals: a max of 0 means nothing fired
295    if best_score <= 0 {
296        return Archetype::Unknown;
297    }
298    best
299}
300
301/// Fixed tie-break rank: lower rank wins (first in PRECEDENCE).
302fn rank(a: Archetype) -> usize {
303    Archetype::PRECEDENCE
304        .iter()
305        .position(|p| *p == a)
306        .unwrap_or(usize::MAX)
307}
308
309/// Archetype emphasis (semantic clustering): for each archetype the
310/// clustering priors change — one region trait coheres (+PRIOR_WEIGHT)
311/// when the repository matches that archetype.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum ClusterPrior {
314    /// CLI: command-region files cohere (cli-subcommand/cli_flags symbols).
315    CliCommands,
316    /// Framework: registration-emitting regions cohere (REGISTERS edges).
317    FrameworkRegistrations,
318    /// Service: entrypoint-owning regions cohere (route handlers /
319    /// entrypoint-attributed symbols).
320    ServiceEntrypoints,
321    /// Compiler: phase-named regions cohere (parse/analyze/transform/...).
322    CompilerPhases,
323}
324
325/// Extra weight applied between two regions sharing the prior trait.
326pub const PRIOR_WEIGHT: i32 = 2;
327
328/// The clustering prior for an archetype, or `None` when the archetype adds
329/// no region-trait prior (LibrarySdk's emphasis is the doubled public-surface
330/// cohesion weight in the clustering signal itself; Unknown/Monorepo/Infra/
331/// Plugin rely on the base signal set).
332pub 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
342/// True when a symbol name matches the compiler/language-tool phase
343/// vocabulary (`parse`/`analyze`/`transform`/`generate`/...). Shared with
344/// archetype detection so the clustering prior uses the same vocabulary.
345pub 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    /// Minimal harness: open a store, run a closure that inserts facts,
357    /// then detect.
358    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        // routes + framework imports but NO middleware/registrations:
597        // ServiceApplication and WebFramework both score 3 — precedence
598        // must pick WebFramework.
599        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        // WebFramework: routes +2, framework +3 = 5
607        // ServiceApplication: routes +2, handlers>=3? no (2) -> 2
608        // LibrarySdk: ratio 0, routes>0 -> 0
609        assert_eq!(a, Archetype::WebFramework);
610    }
611
612    #[test]
613    fn cli_beats_web_framework_when_axum_router_also_present() {
614        // cli-service fixture shape: clap + cobra + argparse subcommands
615        // AND an axum router with routes. CLI signals must still win.
616        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}