Skip to main content

scc_graph/
flows.rs

1//! Flow Compiler (EPIC-050, docs/FLOW_COMPILER.md).
2//!
3//! Compiles machine-readable Sequence and Data Flow views from entrypoints,
4//! plus one Architecture view per repository.
5//!
6//! Traversal: BFS through RESOLVED call edges, capped in depth and breadth,
7//! recording store/topic/external access; then abstracted to
8//! component-level steps (collapse consecutive same-actor hops).
9
10use crate::components::prov_rank;
11use crate::{RealityGraph, Result};
12use scc_core::kinds;
13use scc_core::{
14    entity_id, Flow, FlowKind, FlowStep, InvocationSurface, InvocationSurfaceKind, Provenance,
15    Relationship,
16};
17use scc_store::Store;
18use serde_json::json;
19use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
20
21const MAX_DEPTH: usize = 10;
22const MAX_BREADTH: usize = 64;
23
24pub struct FlowEntrypoint {
25    pub name: String,
26    pub trigger: String,
27    pub symbol_id: String,
28    pub kind: String, // "route" | "entrypoint" | "intent" | surface kinds
29}
30
31/// JUnit lifecycle annotation names (java extractor emits these as
32/// ANNOTATION facts; the annotated method is a Lifecycle invocation
33/// surface).
34const LIFECYCLE_ANNOTATIONS: [&str; 8] = [
35    "Before",
36    "After",
37    "BeforeClass",
38    "AfterClass",
39    "BeforeAll",
40    "AfterAll",
41    "BeforeEach",
42    "AfterEach",
43];
44
45/// Last path segment of an entity id — the display-name fallback when the
46/// referenced entity does not exist in the graph (matches `name_of`).
47fn last_segment(id: &str) -> String {
48    id.rsplit('/').next().unwrap_or(id).to_string()
49}
50
51/// Seed invocation surfaces from the Wave 9 semantic-fact layer
52/// (deterministic — sorted by (kind, symbol, trigger)):
53/// - public exports: `symbol EXPORTS export` relationships → PublicApi
54/// - exported module-level symbols + methods of exported classes (the
55///   `exported: true` fact attribute / class-parent attribution) → PublicApi
56/// - queue consumers: `symbol SUBSCRIBES topic` relationships → Queue
57/// - framework callbacks: `owner HANDLES_CALLBACK callback` → FrameworkCallback
58/// - lifecycle callbacks: JUnit @Before*/@After* annotation facts → Lifecycle
59/// - event handlers: `symbol CONSUMES|PUBLISHES topic` relationships → Event
60/// - process entrypoints (`main-guard`/`bin` entrypoint attrs) → Process
61/// - http surfaces: ROUTE entities (handler symbol) → Http
62/// - cli surfaces: `cli_flags` attrs / `cli` entrypoint kind → Cli
63/// - scheduled jobs: @Scheduled annotation facts → Schedule
64/// - plugin registrations: REGISTERS to `plugin`-kind contracts → Plugin
65// trace:v1 id=impl.scc.flows.surfaces work=WORK-SCC-005 satisfies=REQ-SCC-IR
66pub fn invocation_surfaces(graph: &RealityGraph) -> Vec<InvocationSurface> {
67    let mut out: Vec<InvocationSurface> = Vec::new();
68    // dedup key: (kind, symbol id) — keep the first occurrence after a
69    // deterministic sort so multi-trigger symbols pick a stable trigger
70    let mut seen: HashSet<(InvocationSurfaceKind, String)> = HashSet::new();
71
72    // relationships processed in a deterministic order
73    let mut rels: Vec<&Relationship> = graph.all_rels();
74    rels.sort_by(|a, b| {
75        a.subject
76            .cmp(&b.subject)
77            .then(a.predicate.cmp(&b.predicate))
78            .then(a.object.cmp(&b.object))
79    });
80
81    // public exports → PublicApi
82    for r in rels.iter().copied() {
83        if r.predicate != scc_core::predicates::EXPORTS {
84            continue;
85        }
86        let Some(sym) = graph.entity(&r.subject) else { continue };
87        if sym.name.is_empty() {
88            continue;
89        }
90        let kind_attr = graph
91            .entity(&r.object)
92            .and_then(|e| e.attributes.get("kind"))
93            .and_then(|v| v.as_str())
94            .unwrap_or("");
95        let key = (InvocationSurfaceKind::PublicApi, r.subject.clone());
96        if seen.insert(key.clone()) {
97            out.push(InvocationSurface {
98                symbol: r.subject.clone(),
99                kind: InvocationSurfaceKind::PublicApi,
100                trigger: if kind_attr.is_empty() {
101                    format!("export:{}", sym.name)
102                } else {
103                    format!("export:{} ({kind_attr})", sym.name)
104                },
105            });
106        }
107    }
108
109    // exported module-level symbols + methods of exported classes → PublicApi.
110    // The extractors record visibility statically (`exported: true`) and
111    // attribute methods to their parent class; a method of an exported class
112    // is part of the class's public surface. Deterministic: symbols are
113    // iterated by id.
114    let exported_classes = exported_class_names(graph);
115    let mut exported_syms: Vec<&scc_core::Entity> = graph
116        .entities_of_kind(kinds::SYMBOL)
117        .into_iter()
118        .filter(|e| {
119            let name = e.name.as_str();
120            if name.is_empty() || name.starts_with('_') {
121                return false;
122            }
123            let exported = e
124                .attributes
125                .get("exported")
126                .and_then(|v| v.as_bool())
127                == Some(true);
128            if exported {
129                return !name.contains('.');
130            }
131            // method of an exported class
132            let parent = e
133                .attributes
134                .get("parent")
135                .and_then(|v| v.as_str())
136                .unwrap_or("");
137            !parent.is_empty()
138                && exported_classes.contains(parent)
139                && e.attributes.get("kind").and_then(|v| v.as_str()) == Some("method")
140        })
141        .collect();
142    exported_syms.sort_by(|a, b| a.name.cmp(&b.name));
143    for e in exported_syms {
144        let key = (InvocationSurfaceKind::PublicApi, e.id.clone());
145        if seen.insert(key.clone()) {
146            let kind = e
147                .attributes
148                .get("kind")
149                .and_then(|v| v.as_str())
150                .unwrap_or("");
151            out.push(InvocationSurface {
152                symbol: e.id.clone(),
153                kind: InvocationSurfaceKind::PublicApi,
154                trigger: if kind.is_empty() {
155                    format!("export:{}", e.name)
156                } else {
157                    format!("export:{} ({kind})", e.name)
158                },
159            });
160        }
161    }
162
163    // queue consumers → Queue
164    for r in rels.iter().copied() {
165        if r.predicate != scc_core::predicates::SUBSCRIBES {
166            continue;
167        }
168        if graph.entity(&r.subject).is_none() {
169            continue;
170        }
171        let target = graph
172            .entity(&r.object)
173            .map(|e| e.name.clone())
174            .unwrap_or_else(|| last_segment(&r.object));
175        let key = (InvocationSurfaceKind::Queue, r.subject.clone());
176        if seen.insert(key.clone()) {
177            out.push(InvocationSurface {
178                symbol: r.subject.clone(),
179                kind: InvocationSurfaceKind::Queue,
180                trigger: format!("subscribe:{target}"),
181            });
182        }
183    }
184
185    // framework callbacks → FrameworkCallback
186    for r in rels.iter().copied() {
187        if r.predicate != scc_core::predicates::HANDLES_CALLBACK {
188            continue;
189        }
190        if graph.entity(&r.subject).is_none() {
191            continue;
192        }
193        let cb = graph
194            .entity(&r.object)
195            .map(|e| e.name.clone())
196            .unwrap_or_else(|| last_segment(&r.object));
197        let key = (InvocationSurfaceKind::FrameworkCallback, r.subject.clone());
198        if seen.insert(key.clone()) {
199            out.push(InvocationSurface {
200                symbol: r.subject.clone(),
201                kind: InvocationSurfaceKind::FrameworkCallback,
202                trigger: format!("callback:{cb}"),
203            });
204        }
205    }
206
207    // lifecycle callbacks (JUnit @Before*/@After* annotation facts) →
208    // Lifecycle: the annotation entity names the hook, the ANNOTATES
209    // target is the lifecycle method.
210    for a in graph.entities_of_kind(kinds::ANNOTATION) {
211        if !LIFECYCLE_ANNOTATIONS.contains(&a.name.as_str()) {
212            continue;
213        }
214        for r in graph.out_pred(&a.id, scc_core::predicates::ANNOTATES) {
215            let key = (InvocationSurfaceKind::Lifecycle, r.object.clone());
216            if seen.insert(key.clone()) {
217                out.push(InvocationSurface {
218                    symbol: r.object.clone(),
219                    kind: InvocationSurfaceKind::Lifecycle,
220                    trigger: format!("lifecycle:{}", a.name),
221                });
222            }
223        }
224    }
225
226    // event handlers → Event (topics with CONSUMES/PUBLISHES edges)
227    for t in graph.entities_of_kind(kinds::TOPIC) {
228        let mut handlers: BTreeMap<String, String> = BTreeMap::new(); // symbol -> trigger
229        for pred in [
230            scc_core::predicates::CONSUMES,
231            scc_core::predicates::PUBLISHES,
232        ] {
233            for r in graph.in_pred(&t.id, pred) {
234                handlers.entry(r.subject.clone()).or_insert_with(|| format!("event:{}", t.name));
235            }
236        }
237        let mut handlers: Vec<(String, String)> = handlers.into_iter().collect();
238        handlers.sort();
239        for (sym, trigger) in handlers {
240            let key = (InvocationSurfaceKind::Event, sym.clone());
241            if seen.insert(key.clone()) {
242                out.push(InvocationSurface {
243                    symbol: sym,
244                    kind: InvocationSurfaceKind::Event,
245                    trigger,
246                });
247            }
248        }
249    }
250
251    // process entrypoints → Process: symbols whose `entrypoints` attribute
252    // names a process surface (main-guard / bin / process).
253    let mut process_syms: Vec<&scc_core::Entity> = graph
254        .entities_of_kind(kinds::SYMBOL)
255        .into_iter()
256        .filter(|e| {
257            e.attributes
258                .get("entrypoints")
259                .and_then(|v| v.as_array())
260                .map(|eps| {
261                    eps.iter().any(|k| {
262                        matches!(k.as_str(), Some("main-guard") | Some("bin") | Some("process"))
263                    })
264                })
265                .unwrap_or(false)
266        })
267        .collect();
268    process_syms.sort_by(|a, b| a.id.cmp(&b.id));
269    for e in process_syms {
270        let key = (InvocationSurfaceKind::Process, e.id.clone());
271        if seen.insert(key.clone()) {
272            out.push(InvocationSurface {
273                symbol: e.id.clone(),
274                kind: InvocationSurfaceKind::Process,
275                trigger: format!("process:{}", e.name),
276            });
277        }
278    }
279
280    // http surfaces → Http: ROUTE entities (the handler symbol, method+path
281    // trigger). The ROUTE entity itself keeps its dedicated `route`
282    // entrypoint kind in the atlas; the handler symbol carries the http
283    // surface kind.
284    let mut routes: Vec<&scc_core::Entity> = graph.entities_of_kind(kinds::ROUTE);
285    routes.sort_by(|a, b| a.id.cmp(&b.id));
286    for r in routes {
287        let Some(handler) = r.attributes.get("handler").and_then(|v| v.as_str()) else {
288            continue;
289        };
290        if graph.entity(handler).is_none() {
291            continue;
292        }
293        let method = r.attributes.get("method").and_then(|v| v.as_str()).unwrap_or("");
294        let path = r.attributes.get("path").and_then(|v| v.as_str()).unwrap_or("");
295        let trigger = format!("{method} {path}").trim().to_string();
296        let key = (InvocationSurfaceKind::Http, handler.to_string());
297        if seen.insert(key.clone()) {
298            out.push(InvocationSurface {
299                symbol: handler.to_string(),
300                kind: InvocationSurfaceKind::Http,
301                trigger,
302            });
303        }
304    }
305
306    // cli surfaces → Cli: symbols with `cli_flags` attributes or an
307    // explicit `cli` entrypoint kind. `cli-subcommand` entrypoints keep
308    // their dedicated kind (they already render as such in the atlas).
309    let mut cli_syms: Vec<&scc_core::Entity> = graph
310        .entities_of_kind(kinds::SYMBOL)
311        .into_iter()
312        .filter(|e| {
313            let flags = e
314                .attributes
315                .get("cli_flags")
316                .and_then(|v| v.as_array())
317                .map(|fl| !fl.is_empty())
318                .unwrap_or(false);
319            let cli_ep = e
320                .attributes
321                .get("entrypoints")
322                .and_then(|v| v.as_array())
323                .map(|eps| eps.iter().any(|k| k.as_str() == Some("cli")))
324                .unwrap_or(false);
325            flags || cli_ep
326        })
327        .collect();
328    cli_syms.sort_by(|a, b| a.id.cmp(&b.id));
329    for e in cli_syms {
330        let key = (InvocationSurfaceKind::Cli, e.id.clone());
331        if seen.insert(key.clone()) {
332            out.push(InvocationSurface {
333                symbol: e.id.clone(),
334                kind: InvocationSurfaceKind::Cli,
335                trigger: format!("cli:{}", e.name),
336            });
337        }
338    }
339
340    // scheduled jobs → Schedule: @Scheduled annotation facts (Spring)
341    // annotating the job method.
342    const SCHEDULE_ANNOTATIONS: [&str; 2] = ["Scheduled", "Schedules"];
343    for a in graph.entities_of_kind(kinds::ANNOTATION) {
344        if !SCHEDULE_ANNOTATIONS.contains(&a.name.as_str()) {
345            continue;
346        }
347        let mut rels = graph.out_pred(&a.id, scc_core::predicates::ANNOTATES);
348        rels.sort_by(|a, b| a.id.cmp(&b.id));
349        for r in rels {
350            let key = (InvocationSurfaceKind::Schedule, r.object.clone());
351            if seen.insert(key.clone()) {
352                out.push(InvocationSurface {
353                    symbol: r.object.clone(),
354                    kind: InvocationSurfaceKind::Schedule,
355                    trigger: format!("schedule:{}", a.name),
356                });
357            }
358        }
359    }
360
361    // plugin registrations → Plugin: REGISTERS edges whose target is a
362    // CONTRACT entity carrying a `plugin` registration kind.
363    for r in rels.iter().copied() {
364        if r.predicate != scc_core::predicates::REGISTERS {
365            continue;
366        }
367        if graph.entity(&r.subject).is_none() {
368            continue;
369        }
370        let is_plugin = graph
371            .entity(&r.object)
372            .map(|e| {
373                e.kind == kinds::CONTRACT
374                    && e.attributes.get("kind").and_then(|v| v.as_str()) == Some("plugin")
375            })
376            .unwrap_or(false);
377        if !is_plugin {
378            continue;
379        }
380        let target = graph
381            .entity(&r.object)
382            .map(|e| e.name.clone())
383            .unwrap_or_default();
384        let key = (InvocationSurfaceKind::Plugin, r.subject.clone());
385        if seen.insert(key.clone()) {
386            out.push(InvocationSurface {
387                symbol: r.subject.clone(),
388                kind: InvocationSurfaceKind::Plugin,
389                trigger: format!("register:{target}"),
390            });
391        }
392    }
393
394    out.sort_by(|a, b| {
395        a.kind
396            .cmp(&b.kind)
397            .then(a.symbol.cmp(&b.symbol))
398            .then(a.trigger.cmp(&b.trigger))
399    });
400    out
401}
402
403/// Module-level symbol kinds that count as an exported *class* (a method
404/// parent attribution target). The extractor's `exported: true` fact on a
405/// top-level class/struct/trait/interface/type marks the class as public API.
406const CLASS_KINDS: [&str; 13] = [
407    "class",
408    "struct",
409    "trait",
410    "interface",
411    "enum",
412    "type",
413    "module",
414    "protocol",
415    "dataclass",
416    "object",
417    "decorator",
418    "exception",
419    "model",
420];
421
422/// Names of exported classes (deterministic: sorted by id over symbol
423/// entities). Methods of these classes are public-api surfaces.
424fn exported_class_names(graph: &RealityGraph) -> BTreeSet<String> {
425    let mut out: BTreeSet<String> = BTreeSet::new();
426    for e in graph.entities_of_kind(kinds::SYMBOL) {
427        let name = e.name.as_str();
428        if name.is_empty() || name.contains('.') {
429            continue;
430        }
431        let kind = e
432            .attributes
433            .get("kind")
434            .and_then(|v| v.as_str())
435            .unwrap_or("");
436        if !CLASS_KINDS.contains(&kind) {
437            continue;
438        }
439        if e.attributes.get("exported").and_then(|v| v.as_bool()) == Some(true) {
440            out.insert(name.to_string());
441        }
442    }
443    out
444}
445
446/// Public-api flow seeds are budgeted in two deterministic classes so the
447/// FLOWS view stays compact (the full surface list still reaches the atlas
448/// entrypoints) while the budget spends itself on behavior:
449///
450/// - **chain surfaces** seed first: an exported symbol with outgoing
451///   EXTRACTED/RESOLVED CALLS edges is a behavior-flow candidate. Within
452///   the class the order is by resolved-chain length (deepest first), then
453///   symbol id — NOT alphabetical file order, which let benchmark/script/
454///   error-constant noise crowd out the library's real API in chain-rich
455///   repos (e.g. a framework repo with hundreds of exported symbols).
456/// - **leaf surfaces** (single-step exports) fill the remainder.
457const PUBLIC_API_CHAIN_SEED_CAP: usize = 48;
458const PUBLIC_API_LEAF_SEED_CAP: usize = 16;
459
460/// Length of the longest call chain `walk_calls` reaches from `sym` (node
461/// count; 1 when the symbol has no evidence-grade call edges). Cheap for
462/// leaves (no edges -> immediate return).
463fn chain_length(graph: &RealityGraph, sym: &str) -> usize {
464    walk_calls(graph, sym)
465        .into_iter()
466        .map(|p| p.len())
467        .max()
468        .unwrap_or(1)
469}
470
471/// Collect entrypoints: routes (handlers), declared intent flows, symbols
472/// marked as entrypoints (main-guard / bin / module-entry).
473pub fn collect_entrypoints(
474    graph: &RealityGraph,
475    store: &Store,
476    intent: &[(String, serde_json::Value)],
477) -> Vec<FlowEntrypoint> {
478    let mut out: Vec<FlowEntrypoint> = Vec::new();
479    // Flow ids key on the entrypoint name via `entity_id`, which sanitizes
480    // names to lowercase; names that differ only by case (`main` / `Main`)
481    // collide on the store's UNIQUE flows.id. Dedup on the canonical flow
482    // id so the id constraint is the invariant, never the raw spelling.
483    let mut seen: HashSet<String> = HashSet::new();
484    let flow_key = |name: &str| entity_id(&store.repo_id, kinds::FLOW, name);
485
486    // routes
487    for route in graph.entities_of_kind(kinds::ROUTE) {
488        if let Some(handler) = route.attributes.get("handler").and_then(|v| v.as_str()) {
489            let method = route.attributes.get("method").and_then(|v| v.as_str()).unwrap_or("");
490            let path = route.attributes.get("path").and_then(|v| v.as_str()).unwrap_or("");
491            let name = format!("{}-{}", method.to_ascii_lowercase(), path);
492            if seen.insert(flow_key(&name)) {
493                out.push(FlowEntrypoint {
494                    name,
495                    trigger: format!("{method} {path}"),
496                    symbol_id: handler.to_string(),
497                    kind: "route".into(),
498                });
499            }
500        }
501    }
502
503    // intent flows
504    for (source, claim) in intent {
505        if source == "flow" {
506            let name = claim["name"].as_str().unwrap_or("").to_string();
507            let entrypoint = claim["entrypoint"].as_str().unwrap_or("").to_string();
508            if name.is_empty() || entrypoint.is_empty() {
509                continue;
510            }
511            // find symbol by name anywhere in the repo
512            let symbol_id = find_symbol_by_name(graph, &entrypoint);
513            let trigger = claim
514                .get("trigger")
515                .and_then(|v| v.as_str())
516                .map(|s| s.to_string())
517                .unwrap_or_else(|| entrypoint.clone());
518            if seen.insert(flow_key(&name)) {
519                out.push(FlowEntrypoint {
520                    name,
521                    trigger,
522                    symbol_id: symbol_id.unwrap_or_default(),
523                    kind: "intent".into(),
524                });
525            }
526        }
527    }
528
529    // symbols with entrypoint attributes
530    for e in graph.entities_of_kind(kinds::SYMBOL) {
531        let has_ep = e.attributes.contains_key("entrypoints");
532        if !has_ep {
533            continue;
534        }
535        if seen.insert(flow_key(&e.name)) {
536            out.push(FlowEntrypoint {
537                name: e.name.clone(),
538                trigger: format!("entrypoint:{}", e.name),
539                symbol_id: e.id.clone(),
540                kind: "entrypoint".into(),
541            });
542        }
543    }
544
545    // Wave 9/10: invocation-surface seeds (public exports, exported-module
546    // and exported-class-method surfaces, queue consumers, framework
547    // callbacks, lifecycle callbacks, event handlers) — additive and
548    // deterministic. Name-dedup keeps flow ids unique (a symbol already
549    // seeded as an entrypoint does not seed a second flow).
550    //
551    // The public-api bulk (every exported symbol in a framework repo) is
552    // bounded by class so the FLOWS view stays compact; the full surface
553    // list still reaches the atlas entrypoints. Chain surfaces (exports
554    // whose walk reaches real callees) seed FIRST — the behavior flows —
555    // ordered by chain length descending, then leaf exports; this keeps
556    // the cap from being spent on alphabetical file-order noise
557    // (benchmarks, scripts, error constants) at the expense of the
558    // library's real API. Non-public-api surfaces (queue consumers,
559    // framework/lifecycle callbacks, event handlers) are naturally few and
560    // seed uncapped in invocation_surfaces' deterministic order.
561    let mut surfaces = invocation_surfaces(graph);
562    let mut public_api: Vec<InvocationSurface> = Vec::new();
563    let mut others: Vec<InvocationSurface> = Vec::new();
564    for s in surfaces.drain(..) {
565        if s.kind == InvocationSurfaceKind::PublicApi {
566            public_api.push(s);
567        } else {
568            others.push(s);
569        }
570    }
571    let mut chained: Vec<(usize, InvocationSurface)> = Vec::new();
572    let mut leaves: Vec<InvocationSurface> = Vec::new();
573    for s in public_api {
574        let len = chain_length(graph, &s.symbol);
575        if len >= 2 {
576            chained.push((len, s));
577        } else {
578            leaves.push(s);
579        }
580    }
581    chained.sort_by(|a, b| {
582        b.0.cmp(&a.0)
583            .then(a.1.symbol.cmp(&b.1.symbol))
584            .then(a.1.trigger.cmp(&b.1.trigger))
585    });
586    leaves.sort_by(|a, b| a.symbol.cmp(&b.symbol).then(a.trigger.cmp(&b.trigger)));
587    let mut seed_public = |s: &InvocationSurface, out: &mut Vec<FlowEntrypoint>| {
588        let Some(e) = graph.entity(&s.symbol) else { return };
589        if e.name.is_empty() {
590            return;
591        }
592        if seen.insert(flow_key(&e.name)) {
593            out.push(FlowEntrypoint {
594                name: e.name.clone(),
595                trigger: s.trigger.clone(),
596                symbol_id: s.symbol.clone(),
597                kind: s.kind.as_str().to_string(),
598            });
599        }
600    };
601    let mut chain_seeded = 0usize;
602    for (_, s) in chained {
603        if chain_seeded >= PUBLIC_API_CHAIN_SEED_CAP {
604            break;
605        }
606        let before = out.len();
607        seed_public(&s, &mut out);
608        if out.len() > before {
609            chain_seeded += 1;
610        }
611    }
612    let mut leaf_seeded = 0usize;
613    for s in leaves {
614        if leaf_seeded >= PUBLIC_API_LEAF_SEED_CAP {
615            break;
616        }
617        let before = out.len();
618        seed_public(&s, &mut out);
619        if out.len() > before {
620            leaf_seeded += 1;
621        }
622    }
623    for s in others {
624        seed_public(&s, &mut out);
625    }
626    let _ = store;
627    out
628}
629
630pub fn find_symbol_by_name(graph: &RealityGraph, name: &str) -> Option<String> {
631    graph
632        .entities_of_kind(kinds::SYMBOL)
633        .into_iter()
634        .find(|e| e.name == name)
635        .map(|e| e.id.clone())
636}
637
638/// Walk evidence-grade call edges from an entry symbol, returning all
639/// reachable call paths (each capped by MAX_DEPTH).
640///
641/// Evidence grade: EXTRACTED (native extractor candidates — same-file
642/// calls whose target the native resolver found) and RESOLVED (LSP/SCIP
643/// proof). Flows seed from BOTH so behavior exists WITHOUT a language
644/// server — holdout repos are indexed natively and must still emit flows.
645/// RESOLVED edges are explored first (a deterministic, provenance-ranked
646/// expansion), so LSP-backed chains stay the preferred spine while native
647/// chains remain first-class; downstream compilers attach each edge's
648/// honest provenance (Extracted vs Resolved) to the flow steps.
649pub(crate) fn walk_calls(
650    graph: &RealityGraph,
651    entry: &str,
652) -> Vec<Vec<String>> {
653    let mut paths: Vec<Vec<String>> = Vec::new();
654    let mut queue: VecDeque<(String, Vec<String>)> = VecDeque::new();
655    queue.push_back((entry.to_string(), vec![entry.to_string()]));
656    let mut visited: HashMap<String, usize> = HashMap::new();
657    let mut breadth = 0;
658    while let Some((sym, path)) = queue.pop_front() {
659        breadth += 1;
660        if breadth > MAX_BREADTH {
661            break;
662        }
663        if path.len() > MAX_DEPTH {
664            paths.push(path);
665            continue;
666        }
667        let mut call_targets: Vec<(u8, String)> = graph
668            .out_pred(&sym, scc_core::predicates::CALLS)
669            .into_iter()
670            // evidence-grade edges only: EXTRACTED (native candidates) and
671            // RESOLVED (LSP/SCIP proof) — never INFERRED/STALE
672            .filter(|r| matches!(r.provenance, Provenance::Extracted | Provenance::Resolved))
673            .map(|r| (prov_rank(r.provenance), r.object.clone()))
674            .collect();
675        // RESOLVED first, then target id — deterministic and
676        // proof-preferred: when a symbol has both an LSP-resolved edge and
677        // a native candidate to the same target, the resolved chain is
678        // explored before the native one.
679        call_targets.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
680        let call_targets: Vec<String> = call_targets.into_iter().map(|(_, t)| t).collect();
681        if call_targets.is_empty() {
682            paths.push(path);
683            continue;
684        }
685        let mut any_new = false;
686        for t in &call_targets {
687            let seen_at = visited.get(t).copied().unwrap_or(usize::MAX);
688            if seen_at <= path.len() {
689                continue; // cycle or already explored closer
690            }
691            visited.insert(t.clone(), path.len());
692            any_new = true;
693            let mut np = path.clone();
694            np.push(t.clone());
695            queue.push_back((t.clone(), np));
696        }
697        if !any_new {
698            paths.push(path);
699        }
700    }
701    paths
702}
703
704/// Flow-participation groups for semantic clustering: for every flow
705/// entrypoint (routes, entrypoint-attributed symbols, public exports,
706/// queue/callback/event surfaces), the set of symbols reachable via
707/// evidence-grade call chains (the same walk the flow compiler uses).
708/// Returns `(entry symbol, reachable set)` pairs so callers can exclude
709/// verification-seeded flows. Regions whose symbols share a flow cohere
710/// (+3). Deterministic: `collect_entrypoints` iterates sorted collections
711/// and `walk_calls` is deterministic, so the returned groups are stable for
712/// identical graphs.
713// trace:v1 id=impl.scc.flows work=WORK-SCC-005 satisfies=REQ-SCC-FLOW
714pub fn flow_participant_groups(
715    graph: &RealityGraph,
716    store: &Store,
717    intent: &[(String, serde_json::Value)],
718) -> Vec<(String, BTreeSet<String>)> {
719    let mut groups: Vec<(String, BTreeSet<String>)> = Vec::new();
720    for ep in collect_entrypoints(graph, store, intent) {
721        if ep.symbol_id.is_empty() {
722            continue;
723        }
724        let mut members: BTreeSet<String> = BTreeSet::new();
725        members.insert(ep.symbol_id.clone());
726        for path in walk_calls(graph, &ep.symbol_id) {
727            members.extend(path);
728        }
729        // single-symbol flows form no pair — nothing to cohere
730        if members.len() >= 2 {
731            groups.push((ep.symbol_id.clone(), members));
732        }
733    }
734    groups
735}
736
737pub fn compile_flows(
738    graph: &RealityGraph,
739    store: &Store,
740    intent: &[(String, serde_json::Value)],
741) -> Result<(Vec<Flow>, Vec<Flow>, Option<Flow>)> {
742    // symbol -> component entity id via the STORED components (stage-1
743    // semantic clustering results): component CONTAINS file edges -> file
744    // CONTAINS symbol edges. The clustering result replaces the old
745    // longest-prefix path assignment, so flows must read the same
746    // boundaries the component compiler produced — never a rebuilt
747    // candidate guess.
748    let mut file_comp: HashMap<String, String> = HashMap::new();
749    for c in graph.entities_of_kind(kinds::COMPONENT) {
750        for r in graph.out_pred(&c.id, scc_core::predicates::CONTAINS) {
751            file_comp.insert(r.object.clone(), c.name.clone());
752        }
753    }
754    let mut symbol_comp: HashMap<String, String> = HashMap::new();
755    let comp_name_to_id: HashMap<String, String> = graph
756        .entities_of_kind(kinds::COMPONENT)
757        .into_iter()
758        .map(|c| (c.name.clone(), c.id.clone()))
759        .collect();
760    for e in graph.entities_of_kind(kinds::SYMBOL) {
761        if let Some(file) = e.attributes.get("file").and_then(|v| v.as_str()) {
762            let file_id = entity_id(&graph.repo_id, kinds::FILE, file);
763            let comp = file_comp
764                .get(&file_id)
765                .cloned()
766                .unwrap_or_else(|| "root".to_string());
767            let cid = comp_name_to_id
768                .get(&comp)
769                .cloned()
770                .unwrap_or_else(|| format!("component:{comp}"));
771            symbol_comp.insert(e.id.clone(), cid);
772        }
773    }
774
775    // store/topic access per symbol
776    let mut store_access: HashMap<String, Vec<(String, String, Provenance)>> = HashMap::new();
777    for r in graph.all_rels() {
778        if matches!(
779            r.predicate.as_str(),
780            "reads" | "writes" | "queries" | "publishes" | "subscribes"
781        ) {
782            store_access
783                .entry(r.subject.clone())
784                .or_default()
785                .push((r.predicate.clone(), r.object.clone(), r.provenance));
786        }
787    }
788
789    let entrypoints = collect_entrypoints(graph, store, intent);
790    let mut sequences: Vec<Flow> = Vec::new();
791    let mut dataflows: Vec<Flow> = Vec::new();
792
793    for ep in entrypoints {
794        if ep.symbol_id.is_empty() {
795            // declared entrypoint missing → drift handled elsewhere
796            continue;
797        }
798        let paths = walk_calls(graph, &ep.symbol_id);
799        // merge all paths into one ordered step list: keep a canonical
800        // traversal order = entry first, then order of first appearance
801        let mut step_evidence: BTreeMap<(String, String), (Provenance, Vec<String>)> = BTreeMap::new();
802        let step_async: HashSet<String> = HashSet::new();
803        let mut step_retries: HashMap<String, String> = HashMap::new();
804        let mut store_steps: Vec<(String, String, Provenance)> = Vec::new();
805
806        let push_step = |actor: &str, op: &str, prov: Provenance, ev_ids: Vec<String>,
807                             steps: &mut Vec<(String, String)>,
808                             seen: &mut HashMap<(String, String), usize>,
809                             meta: &mut BTreeMap<(String, String), (Provenance, Vec<String>)>| {
810            let key = (actor.to_string(), op.to_string());
811            if let Some(idx) = seen.get(&key) {
812                let existing = meta.get_mut(&key).unwrap();
813                if prov_rank(prov) > prov_rank(existing.0) {
814                    existing.0 = prov;
815                }
816                for e in ev_ids {
817                    if !existing.1.contains(&e) {
818                        existing.1.push(e);
819                    }
820                }
821                let _ = idx;
822                return;
823            }
824            seen.insert(key.clone(), steps.len());
825            meta.insert(key.clone(), (prov, ev_ids));
826            steps.push(key);
827        };
828
829        let mut seen_steps: HashMap<(String, String), usize> = HashMap::new();
830        let mut steps: Vec<(String, String)> = Vec::new();
831
832        // entry step
833        let entry_actor = symbol_comp
834            .get(&ep.symbol_id)
835            .cloned()
836            .unwrap_or_else(|| "component:root".into());
837        let entry_op = graph
838            .entities
839            .get(&ep.symbol_id)
840            .map(|e| e.name.clone())
841            .unwrap_or_else(|| ep.name.clone());
842        push_step(
843            &entry_actor,
844            &entry_op,
845            Provenance::Resolved,
846            Vec::new(),
847            &mut steps,
848            &mut seen_steps,
849            &mut step_evidence,
850        );
851
852        for path in &paths {
853            for (i, sym) in path.iter().enumerate() {
854                if i == 0 {
855                    continue;
856                }
857                let prev = &path[i - 1];
858                let rel = graph
859                    .out_pred(prev, scc_core::predicates::CALLS)
860                    .into_iter()
861                    .find(|r| r.object == *sym)
862                    .cloned();
863                let prov = rel
864                    .as_ref()
865                    .map(|r| r.provenance)
866                    .unwrap_or(Provenance::Inferred);
867                let ev_ids = rel.as_ref().map(|r| r.evidence.clone()).unwrap_or_default();
868                let actor = symbol_comp
869                    .get(sym)
870                    .cloned()
871                    .unwrap_or_else(|| "component:root".into());
872                let op = graph
873                    .entities
874                    .get(sym)
875                    .map(|e| e.name.clone())
876                    .unwrap_or_else(|| sym.clone());
877                push_step(
878                    &actor,
879                    &op,
880                    prov,
881                    ev_ids,
882                    &mut steps,
883                    &mut seen_steps,
884                    &mut step_evidence,
885                );
886                // retry policy on the callee
887                if let Some(e) = graph.entities.get(sym) {
888                    if let Some(rp) = e.attributes.get("retry_policy").and_then(|v| v.as_str()) {
889                        step_retries.insert(op.clone(), rp.to_string());
890                    }
891                }
892                // store/topic access by callee
893                if let Some(accesses) = store_access.get(sym) {
894                    for (pred, obj, sprov) in accesses {
895                        if let Some(target) = graph.entities.get(obj) {
896                            store_steps.push((target.name.clone(), pred.clone(), *sprov));
897                        }
898                    }
899                }
900            }
901        }
902
903        // Each operation is its own step (P1 §20): collapsing consecutive
904        // same-actor operations into a comma-joined string destroyed the
905        // per-operation evidence keys and produced text that looked like
906        // branching. Canonical per-operation steps keep provenance and
907        // evidence attached.
908        let mut fsteps: Vec<FlowStep> = Vec::new();
909        for (i, (actor, op)) in steps.iter().enumerate() {
910            let meta = step_evidence.get(&(actor.clone(), op.clone()));
911            let prov = meta.map(|(p, _)| *p);
912            let ev = meta.map(|(_, e)| e.clone()).unwrap_or_default();
913            let mut fs = FlowStep {
914                id: format!("step:{}", i + 1),
915                order: (i + 1) as u32,
916                actor: actor.clone(),
917                operation: op.clone(),
918                condition: None,
919                r#async: if step_async.contains(op) { Some(true) } else { None },
920                timeout_ms: None,
921                retry_policy: step_retries.get(op).cloned(),
922                failure_outcome: None,
923                provenance: prov,
924                evidence: ev,
925            };
926            // store steps attached as sub-ops in the operation string
927            let related: Vec<String> = store_steps
928                .iter()
929                .filter(|(n, _, _)| op.contains(n) || n.is_empty())
930                .map(|(n, p, _)| format!("{n}:{p}"))
931                .collect();
932            if !related.is_empty() {
933                fs.condition = Some(format!("stores: {}", related.join(", ")));
934            }
935            fsteps.push(fs);
936        }
937
938        let flow_id = entity_id(&store.repo_id, kinds::FLOW, &ep.name);
939        let mut attrs: BTreeMap<String, serde_json::Value> = BTreeMap::new();
940        attrs.insert("entrypoint".into(), json!(ep.symbol_id));
941        attrs.insert("kind".into(), json!(ep.kind));
942        let seq = Flow {
943            id: flow_id.clone(),
944            kind: FlowKind::Sequence,
945            name: ep.name.clone(),
946            trigger: Some(ep.trigger.clone()),
947            steps: fsteps,
948            attributes: attrs.clone(),
949        };
950        // merge dataflow steps into a dataflow flow
951        if !store_steps.is_empty() {
952            let df_id = entity_id(&store.repo_id, kinds::FLOW, &format!("{}-data", ep.name));
953            let mut dfs: Vec<FlowStep> = Vec::new();
954            let mut seen_df: HashSet<(String, String)> = HashSet::new();
955            for (name, pred, prov) in &store_steps {
956                if !seen_df.insert((name.clone(), pred.clone())) {
957                    continue;
958                }
959                let op = match pred.as_str() {
960                    "writes" => "write",
961                    "reads" => "read",
962                    "queries" => "query",
963                    "publishes" => "publish",
964                    "subscribes" => "subscribe",
965                    _ => pred.as_str(),
966                };
967                dfs.push(FlowStep {
968                    id: format!("step:{}", dfs.len() + 1),
969                    order: (dfs.len() + 1) as u32,
970                    actor: format!("store:{name}"),
971                    operation: op.to_string(),
972                    condition: None,
973                    r#async: Some(pred == "publishes" || pred == "subscribes"),
974                    timeout_ms: None,
975                    retry_policy: None,
976                    failure_outcome: None,
977                    provenance: Some(*prov),
978                    evidence: Vec::new(),
979                });
980            }
981            if !dfs.is_empty() {
982                dataflows.push(Flow {
983                    id: df_id,
984                    kind: FlowKind::Dataflow,
985                    name: format!("{}-data", ep.name),
986                    trigger: Some(ep.trigger),
987                    steps: dfs,
988                    attributes: attrs,
989                });
990            }
991        }
992        sequences.push(seq);
993    }
994
995    // ---- architecture view ----
996    let arch = compile_architecture(graph, store);
997
998    Ok((sequences, dataflows, arch))
999}
1000
1001/// Architecture flow: one step per component with its dependencies and
1002/// responsibilities.
1003fn compile_architecture(_graph: &RealityGraph, store: &Store) -> Option<Flow> {
1004    let comps = store.components().ok()?;
1005    if comps.is_empty() {
1006        return None;
1007    }
1008    let mut steps: Vec<FlowStep> = Vec::new();
1009    for c in comps {
1010        let resp: Vec<String> = c
1011            .attributes
1012            .get("responsibility")
1013            .and_then(|v| v.as_array())
1014            .map(|a| {
1015                a.iter()
1016                    .filter_map(|r| r.get("text").and_then(|t| t.as_str()).map(|s| s.to_string()))
1017                    .collect()
1018            })
1019            .unwrap_or_default();
1020        let deps: Vec<String> = c
1021            .attributes
1022            .get("depends_on")
1023            .and_then(|v| v.as_array())
1024            .map(|a| {
1025                a.iter()
1026                    .filter_map(|d| d.get("target").and_then(|t| t.as_str()).map(|s| s.to_string()))
1027                    .collect()
1028            })
1029            .unwrap_or_default();
1030        steps.push(FlowStep {
1031            id: format!("step:{}", steps.len() + 1),
1032            order: (steps.len() + 1) as u32,
1033            actor: c.id.clone(),
1034            operation: resp.first().cloned().unwrap_or_else(|| c.name.clone()),
1035            condition: if deps.is_empty() {
1036                None
1037            } else {
1038                Some(format!("depends_on: {}", deps.join(", ")))
1039            },
1040            r#async: None,
1041            timeout_ms: None,
1042            retry_policy: None,
1043            failure_outcome: None,
1044            provenance: Some(Provenance::Resolved),
1045            evidence: c.evidence.clone(),
1046        });
1047    }
1048    Some(Flow {
1049        id: entity_id(&store.repo_id, kinds::FLOW, "architecture"),
1050        kind: FlowKind::Architecture,
1051        name: "architecture".into(),
1052        trigger: Some("system".into()),
1053        steps,
1054        attributes: BTreeMap::new(),
1055    })
1056}
1057
1058impl RealityGraph {
1059    pub fn all_rels(&self) -> Vec<&Relationship> {
1060        self.out.values().flatten().collect()
1061    }
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067
1068    #[test]
1069    fn walk_calls_caps_depth_and_cycles() {
1070        // build a tiny graph manually
1071        let dir = tempfile::TempDir::new().unwrap();
1072        let root = dir.path().join("repo");
1073        std::fs::create_dir_all(&root).unwrap();
1074        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1075        let _g = RealityGraph::load(&store).unwrap();
1076        // a -> b -> c -> a (cycle), d
1077        let mk = |n: &str| scc_core::symbol_id("repo", "x.py", n);
1078        for (s, t) in [
1079            ("a", "b"),
1080            ("b", "c"),
1081            ("c", "a"),
1082            ("a", "d"),
1083        ] {
1084            let r = Relationship::new(
1085                crate::components::rel(&["t", s, t]),
1086                mk(s),
1087                "calls",
1088                mk(t),
1089                Provenance::Resolved,
1090            );
1091            store.insert_relationship(&r, "x.py").unwrap();
1092        }
1093        let g = RealityGraph::load(&store).unwrap();
1094        let paths = walk_calls(&g, &mk("a"));
1095        assert!(!paths.is_empty());
1096        // every path starts at a and is cycle-free
1097        for p in &paths {
1098            assert_eq!(p[0], mk("a"));
1099            assert!(p.len() <= MAX_DEPTH + 1);
1100        }
1101    }
1102
1103    #[test]
1104    fn invocation_surfaces_seed_from_semantic_facts() {
1105        let dir = tempfile::TempDir::new().unwrap();
1106        let root = dir.path().join("repo");
1107        std::fs::create_dir_all(&root).unwrap();
1108        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1109        let repo = "repo";
1110        let mk_sym = |n: &str| scc_core::symbol_id(repo, "app.py", n);
1111
1112        // symbols
1113        for n in ["create_app", "worker", "setup", "handler", "listener"] {
1114            let mut e = scc_core::Entity::new(mk_sym(n), kinds::SYMBOL, n);
1115            e.attr("file", serde_json::json!("app.py"));
1116            store.insert_entity(&e, &["app.py".to_string()]).unwrap();
1117        }
1118        // public export: create_app EXPORTS export(create_app, kind=function)
1119        let exp_id = scc_core::entity_id(repo, kinds::EXPORT, "create_app");
1120        let mut exp = scc_core::Entity::new(exp_id.clone(), kinds::EXPORT, "create_app");
1121        exp.attr("kind", serde_json::json!("function"));
1122        store.insert_entity(&exp, &["app.py".to_string()]).unwrap();
1123        store
1124            .insert_relationship(
1125                &Relationship::new(
1126                    scc_core::relationship_id(1),
1127                    mk_sym("create_app"),
1128                    scc_core::predicates::EXPORTS,
1129                    exp_id,
1130                    Provenance::Extracted,
1131                ),
1132                "app.py",
1133            )
1134            .unwrap();
1135        // queue consumer: worker SUBSCRIBES topic(jobs)
1136        let topic_id = scc_core::entity_id(repo, kinds::TOPIC, "jobs");
1137        store
1138            .insert_entity(&scc_core::Entity::new(topic_id.clone(), kinds::TOPIC, "jobs"), &["app.py".to_string()])
1139            .unwrap();
1140        store
1141            .insert_relationship(
1142                &Relationship::new(
1143                    scc_core::relationship_id(2),
1144                    mk_sym("worker"),
1145                    scc_core::predicates::SUBSCRIBES,
1146                    topic_id.clone(),
1147                    Provenance::Extracted,
1148                ),
1149                "app.py",
1150            )
1151            .unwrap();
1152        // event handler: handler CONSUMES topic(jobs)
1153        store
1154            .insert_relationship(
1155                &Relationship::new(
1156                    scc_core::relationship_id(3),
1157                    mk_sym("handler"),
1158                    scc_core::predicates::CONSUMES,
1159                    topic_id,
1160                    Provenance::Extracted,
1161                ),
1162                "app.py",
1163            )
1164            .unwrap();
1165        // framework callback: listener HANDLES_CALLBACK setup
1166        store
1167            .insert_relationship(
1168                &Relationship::new(
1169                    scc_core::relationship_id(4),
1170                    mk_sym("listener"),
1171                    scc_core::predicates::HANDLES_CALLBACK,
1172                    mk_sym("setup"),
1173                    Provenance::Extracted,
1174                ),
1175                "app.py",
1176            )
1177            .unwrap();
1178        // lifecycle: annotation Before ANNOTATES setup
1179        let ann_id = scc_core::entity_id(repo, kinds::ANNOTATION, "Before");
1180        store
1181            .insert_entity(&scc_core::Entity::new(ann_id.clone(), kinds::ANNOTATION, "Before"), &["app.py".to_string()])
1182            .unwrap();
1183        store
1184            .insert_relationship(
1185                &Relationship::new(
1186                    scc_core::relationship_id(5),
1187                    ann_id,
1188                    scc_core::predicates::ANNOTATES,
1189                    mk_sym("setup"),
1190                    Provenance::Extracted,
1191                ),
1192                "app.py",
1193            )
1194            .unwrap();
1195
1196        let g = RealityGraph::load(&store).unwrap();
1197        let surfaces = invocation_surfaces(&g);
1198
1199        let kinds_of = |k: InvocationSurfaceKind| -> Vec<String> {
1200            surfaces
1201                .iter()
1202                .filter(|s| s.kind == k)
1203                .map(|s| format!("{}:{}", s.symbol, s.trigger))
1204                .collect()
1205        };
1206        assert_eq!(
1207            kinds_of(InvocationSurfaceKind::PublicApi),
1208            vec![format!("{}:export:create_app (function)", mk_sym("create_app"))],
1209            "public export surfaces: {surfaces:?}"
1210        );
1211        assert_eq!(
1212            kinds_of(InvocationSurfaceKind::Queue),
1213            vec![format!("{}:subscribe:jobs", mk_sym("worker"))],
1214            "queue surfaces: {surfaces:?}"
1215        );
1216        assert_eq!(
1217            kinds_of(InvocationSurfaceKind::FrameworkCallback),
1218            vec![format!("{}:callback:setup", mk_sym("listener"))],
1219            "callback surfaces: {surfaces:?}"
1220        );
1221        assert_eq!(
1222            kinds_of(InvocationSurfaceKind::Lifecycle),
1223            vec![format!("{}:lifecycle:Before", mk_sym("setup"))],
1224            "lifecycle surfaces: {surfaces:?}"
1225        );
1226        assert_eq!(
1227            kinds_of(InvocationSurfaceKind::Event),
1228            vec![format!("{}:event:jobs", mk_sym("handler"))],
1229            "event surfaces: {surfaces:?}"
1230        );
1231
1232        // deterministic ordering (InvocationSurfaceKind enum order)
1233        let kinds: Vec<&str> = surfaces.iter().map(|s| s.kind.as_str()).collect();
1234        let mut sorted = kinds.clone();
1235        sorted.sort_by_key(|k| match *k {
1236            "process" => 0,
1237            "http" => 1,
1238            "cli" => 2,
1239            "public_api" => 3,
1240            "event" => 4,
1241            "queue" => 5,
1242            "schedule" => 6,
1243            "plugin" => 7,
1244            "framework_callback" => 8,
1245            "lifecycle" => 9,
1246            _ => 10,
1247        });
1248        assert_eq!(kinds, sorted, "surfaces must be deterministically ordered");
1249
1250        // collect_entrypoints picks the surfaces up (kind strings carried)
1251        let eps = collect_entrypoints(&g, &store, &[]);
1252        let surface_kinds: Vec<String> = eps
1253            .iter()
1254            .filter(|e| !matches!(e.kind.as_str(), "route" | "entrypoint" | "intent"))
1255            .map(|e| e.kind.clone())
1256            .collect();
1257        for want in ["public_api", "queue", "framework_callback", "lifecycle", "event"] {
1258            assert!(
1259                surface_kinds.iter().any(|k| k == want),
1260                "entrypoints must include {want}: {surface_kinds:?}"
1261            );
1262        }
1263    }
1264
1265    /// Wave 11: the remaining InvocationSurfaceKind values seed from store
1266    /// facts — process (main-guard entrypoint attr), http (ROUTE handler),
1267    /// cli (cli_flags attr), schedule (@Scheduled annotation), plugin
1268    /// (REGISTERS to a `plugin`-kind contract).
1269    #[test]
1270    // # trace:exempt — unit test (tests are not trace-worthy behavior)
1271    fn process_http_cli_schedule_plugin_surfaces_seed() {
1272        let dir = tempfile::TempDir::new().unwrap();
1273        let root = dir.path().join("repo");
1274        std::fs::create_dir_all(&root).unwrap();
1275        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1276        let repo = "repo";
1277        let mk_sym = |n: &str| scc_core::symbol_id(repo, "app.py", n);
1278
1279        let mk = |n: &str, attrs: serde_json::Value| {
1280            let id = mk_sym(n);
1281            let mut e = scc_core::Entity::new(id.clone(), kinds::SYMBOL, n);
1282            if let Some(obj) = attrs.as_object() {
1283                for (k, v) in obj {
1284                    e.attr(k, v.clone());
1285                }
1286            }
1287            store.insert_entity(&e, &["app.py".to_string()]).unwrap();
1288            id
1289        };
1290        let main_id = mk("main", serde_json::json!({"entrypoints": ["main-guard"]}));
1291        let handler_id = mk("handle_req", serde_json::json!({}));
1292        let cli_id = mk("run", serde_json::json!({"cli_flags": ["--port"]}));
1293        let job_id = mk("daily_job", serde_json::json!({}));
1294        let plugin_id = mk("register_plugin", serde_json::json!({}));
1295
1296        // http: route with handler
1297        let route_id = scc_core::entity_id(repo, kinds::ROUTE, "GET /x");
1298        let mut route = scc_core::Entity::new(route_id.clone(), kinds::ROUTE, "GET /x");
1299        route.attr("method", serde_json::json!("GET"));
1300        route.attr("path", serde_json::json!("/x"));
1301        route.attr("handler", serde_json::json!(handler_id));
1302        store.insert_entity(&route, &["app.py".to_string()]).unwrap();
1303
1304        // schedule: @Scheduled annotation on the job
1305        let ann_id = scc_core::entity_id(repo, kinds::ANNOTATION, "Scheduled");
1306        store
1307            .insert_entity(
1308                &scc_core::Entity::new(ann_id.clone(), kinds::ANNOTATION, "Scheduled"),
1309                &["app.py".to_string()],
1310            )
1311            .unwrap();
1312        store
1313            .insert_relationship(
1314                &Relationship::new(
1315                    scc_core::relationship_id(1),
1316                    ann_id,
1317                    scc_core::predicates::ANNOTATES,
1318                    job_id.clone(),
1319                    Provenance::Extracted,
1320                ),
1321                "app.py",
1322            )
1323            .unwrap();
1324
1325        // plugin: REGISTERS to a `plugin`-kind contract
1326        let plug_contract = scc_core::entity_id(repo, kinds::CONTRACT, "webhook");
1327        let mut ce = scc_core::Entity::new(plug_contract.clone(), kinds::CONTRACT, "webhook");
1328        ce.attr("kind", serde_json::json!("plugin"));
1329        store.insert_entity(&ce, &["app.py".to_string()]).unwrap();
1330        store
1331            .insert_relationship(
1332                &Relationship::new(
1333                    scc_core::relationship_id(2),
1334                    plugin_id.clone(),
1335                    scc_core::predicates::REGISTERS,
1336                    plug_contract,
1337                    Provenance::Extracted,
1338                ),
1339                "app.py",
1340            )
1341            .unwrap();
1342
1343        let g = RealityGraph::load(&store).unwrap();
1344        let surfaces = invocation_surfaces(&g);
1345        let find = |kind: InvocationSurfaceKind, sym: &str| -> Vec<String> {
1346            surfaces
1347                .iter()
1348                .filter(|s| s.kind == kind && s.symbol == sym)
1349                .map(|s| s.trigger.clone())
1350                .collect()
1351        };
1352        assert_eq!(
1353            find(InvocationSurfaceKind::Process, &main_id),
1354            vec!["process:main".to_string()],
1355            "{surfaces:?}"
1356        );
1357        assert_eq!(
1358            find(InvocationSurfaceKind::Http, &handler_id),
1359            vec!["GET /x".to_string()],
1360            "{surfaces:?}"
1361        );
1362        assert_eq!(
1363            find(InvocationSurfaceKind::Cli, &cli_id),
1364            vec!["cli:run".to_string()],
1365            "{surfaces:?}"
1366        );
1367        assert_eq!(
1368            find(InvocationSurfaceKind::Schedule, &job_id),
1369            vec!["schedule:Scheduled".to_string()],
1370            "{surfaces:?}"
1371        );
1372        assert_eq!(
1373            find(InvocationSurfaceKind::Plugin, &plugin_id),
1374            vec!["register:webhook".to_string()],
1375            "{surfaces:?}"
1376        );
1377        // a non-plugin registration (e.g. middleware) is NOT a plugin
1378        // surface — no false positives from bare REGISTERS edges
1379        assert_eq!(
1380            surfaces.iter().filter(|s| s.kind == InvocationSurfaceKind::Plugin).count(),
1381            1
1382        );
1383    }
1384
1385    #[test]
1386    fn walk_calls_follows_extracted_and_prefers_resolved() {
1387        // Behavior flows must exist WITHOUT a language server: the native
1388        // extractor's EXTRACTED call edges are first-class traversal
1389        // evidence. When a symbol has both an EXTRACTED native candidate
1390        // and a RESOLVED (LSP) edge, the resolved chain is explored first
1391        // (proof-preferred), and both chains reach the caller's paths.
1392        let dir = tempfile::TempDir::new().unwrap();
1393        let root = dir.path().join("repo");
1394        std::fs::create_dir_all(&root).unwrap();
1395        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1396        let mk = |n: &str| scc_core::symbol_id("repo", "x.py", n);
1397
1398        let mut rid = 0usize;
1399        let mut rel = |s: &str, t: &str, prov: Provenance| {
1400            rid += 1;
1401            store
1402                .insert_relationship(
1403                    &Relationship::new(
1404                        scc_core::relationship_id(rid as u64),
1405                        mk(s),
1406                        scc_core::predicates::CALLS,
1407                        mk(t),
1408                        prov,
1409                    ),
1410                    "x.py",
1411                )
1412                .unwrap();
1413        };
1414        // entry: native (EXTRACTED) edge to `native_step` AND an
1415        // LSP-resolved edge to `resolved_step`; both continue one hop.
1416        rel("entry", "native_step", Provenance::Extracted);
1417        rel("native_step", "native_leaf", Provenance::Extracted);
1418        rel("entry", "resolved_step", Provenance::Resolved);
1419        rel("resolved_step", "resolved_leaf", Provenance::Resolved);
1420        // INFERRED and STALE edges are never followed
1421        rel("entry", "inferred_step", Provenance::Inferred);
1422        rel("entry", "stale_step", Provenance::Stale);
1423
1424        let g = RealityGraph::load(&store).unwrap();
1425        let paths = walk_calls(&g, &mk("entry"));
1426        assert!(!paths.is_empty(), "entry with no evidence-grade edges yields its own path");
1427
1428        // both evidence-grade chains are reached...
1429        let ends_with = |p: &[String], tail: &[&str]| -> bool {
1430            p.len() >= tail.len()
1431                && tail
1432                    .iter()
1433                    .zip(p.iter().skip(p.len() - tail.len()))
1434                    .all(|(want, have)| have.ends_with(want))
1435        };
1436        assert!(
1437            paths.iter().any(|p| ends_with(p, &["native_step", "native_leaf"])),
1438            "EXTRACTED native chain must be traversed: {paths:?}"
1439        );
1440        assert!(
1441            paths.iter().any(|p| ends_with(p, &["resolved_step", "resolved_leaf"])),
1442            "RESOLVED chain must be traversed: {paths:?}"
1443        );
1444        // ...and the RESOLVED chain is explored first (proof-preferred)
1445        assert!(
1446            paths[0][1] == mk("resolved_step"),
1447            "RESOLVED targets expand before EXTRACTED ones: {paths:?}"
1448        );
1449        // ...but INFERRED/STALE never appear
1450        let all: Vec<String> = paths.iter().map(|p| p.join(">")).collect();
1451        assert!(
1452            !all.iter().any(|p| p.contains("inferred_step") || p.contains("stale_step")),
1453            "only evidence-grade edges are followed: {all:?}"
1454        );
1455    }
1456
1457    #[test]
1458    fn public_api_chain_surfaces_seed_before_leaves() {
1459        // Exported symbols in one file: `deep` reaches a 3-node chain,
1460        // `mid` a 2-node chain, `leaf` has no call edges. The chain class
1461        // must seed before the leaf class, deepest first — even though the
1462        // leaf sorts before both by symbol id (the old id-order cap spent
1463        // itself on exactly this noise).
1464        let dir = tempfile::TempDir::new().unwrap();
1465        let root = dir.path().join("repo");
1466        std::fs::create_dir_all(&root).unwrap();
1467        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1468        let mk = |n: &str| scc_core::symbol_id("repo", "x.py", n);
1469
1470        for n in ["deep", "deep2", "mid", "mid2", "leaf", "leaf2"] {
1471            let mut e = scc_core::Entity::new(mk(n), kinds::SYMBOL, n);
1472            e.attr("file", serde_json::json!("x.py"));
1473            e.attr("exported", serde_json::json!(true));
1474            store.insert_entity(&e, &["x.py".to_string()]).unwrap();
1475        }
1476        // deep -> deep2 -> (leaf2 as a third hop); mid -> mid2
1477        let mut rid = 0usize;
1478        let mut call = |s: &str, t: &str| {
1479            rid += 1;
1480            store
1481                .insert_relationship(
1482                    &Relationship::new(
1483                        scc_core::relationship_id(rid as u64),
1484                        s.to_string(),
1485                        scc_core::predicates::CALLS,
1486                        t.to_string(),
1487                        Provenance::Resolved,
1488                    ),
1489                    "x.py",
1490                )
1491                .unwrap();
1492        };
1493        call(&mk("deep"), &mk("deep2"));
1494        call(&mk("deep2"), &mk("leaf2"));
1495        call(&mk("mid"), &mk("mid2"));
1496
1497        let g = RealityGraph::load(&store).unwrap();
1498        let eps = collect_entrypoints(&g, &store, &[]);
1499        let pub_names: Vec<String> = eps
1500            .iter()
1501            .filter(|e| e.kind == "public_api")
1502            .map(|e| e.name.clone())
1503            .collect();
1504        // deepest chain first, then the 2-node chain, then leaves — the
1505        // chain targets are themselves exported, so deep2 (a 2-node chain
1506        // via its edge to leaf2) and mid (2-node) rank in the chain class;
1507        // mid2 (no outgoing edges) is a leaf.
1508        assert_eq!(
1509            pub_names,
1510            vec!["deep", "deep2", "mid", "leaf", "leaf2", "mid2"],
1511            "chain surfaces (deepest first) seed before leaves: {pub_names:?}"
1512        );
1513    }
1514
1515    #[test]
1516    fn public_api_seed_budgets_are_bounded_by_class() {
1517        // CHAIN_CAP + 5 chained exports and LEAF_CAP + 5 leaves: exactly
1518        // the chain budget is spent on chained surfaces (id order), then
1519        // the leaf budget on leaves; nothing beyond.
1520        let dir = tempfile::TempDir::new().unwrap();
1521        let root = dir.path().join("repo");
1522        std::fs::create_dir_all(&root).unwrap();
1523        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1524        let mk = |n: &str| scc_core::symbol_id("repo", "x.py", n);
1525
1526        let mut rid = 0usize;
1527        for i in 0..(PUBLIC_API_CHAIN_SEED_CAP + 5) {
1528            let c = format!("chain{i:02}");
1529            let t = format!("target{i:02}");
1530            for n in [&c, &t] {
1531                let mut e = scc_core::Entity::new(mk(n), kinds::SYMBOL, n);
1532                e.attr("file", serde_json::json!("x.py"));
1533                e.attr("exported", serde_json::json!(true));
1534                store.insert_entity(&e, &["x.py".to_string()]).unwrap();
1535            }
1536            rid += 1;
1537            store
1538                .insert_relationship(
1539                    &Relationship::new(
1540                        scc_core::relationship_id(rid as u64),
1541                        mk(&c),
1542                        scc_core::predicates::CALLS,
1543                        mk(&t),
1544                        Provenance::Extracted,
1545                    ),
1546                    "x.py",
1547                )
1548                .unwrap();
1549        }
1550        for i in 0..(PUBLIC_API_LEAF_SEED_CAP + 5) {
1551            let n = format!("leaf{i:02}");
1552            let mut e = scc_core::Entity::new(mk(&n), kinds::SYMBOL, &n);
1553            e.attr("file", serde_json::json!("x.py"));
1554            e.attr("exported", serde_json::json!(true));
1555            store.insert_entity(&e, &["x.py".to_string()]).unwrap();
1556        }
1557
1558        let g = RealityGraph::load(&store).unwrap();
1559        let eps = collect_entrypoints(&g, &store, &[]);
1560        let pub_names: Vec<String> = eps
1561            .iter()
1562            .filter(|e| e.kind == "public_api")
1563            .map(|e| e.name.clone())
1564            .collect();
1565        assert_eq!(pub_names.len(), PUBLIC_API_CHAIN_SEED_CAP + PUBLIC_API_LEAF_SEED_CAP);
1566        let chains: Vec<&String> = pub_names.iter().filter(|n| n.starts_with("chain")).collect();
1567        let leaves: Vec<&String> = pub_names.iter().filter(|n| n.starts_with("leaf")).collect();
1568        assert_eq!(chains.len(), PUBLIC_API_CHAIN_SEED_CAP, "chain budget fully spent");
1569        assert_eq!(leaves.len(), PUBLIC_API_LEAF_SEED_CAP, "leaf budget fully spent");
1570        // deterministic id order within each class
1571        assert!(chains.windows(2).all(|w| w[0] < w[1]), "{pub_names:?}");
1572        assert!(leaves.windows(2).all(|w| w[0] < w[1]), "{pub_names:?}");
1573        // chain class entirely precedes the leaf class
1574        let first_leaf = pub_names.iter().position(|n| n.starts_with("leaf")).unwrap();
1575        assert!(chains.iter().all(|n| {
1576            pub_names.iter().position(|p| p == *n).unwrap() < first_leaf
1577        }));
1578    }
1579}