1use 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, }
30
31const LIFECYCLE_ANNOTATIONS: [&str; 8] = [
35 "Before",
36 "After",
37 "BeforeClass",
38 "AfterClass",
39 "BeforeAll",
40 "AfterAll",
41 "BeforeEach",
42 "AfterEach",
43];
44
45fn last_segment(id: &str) -> String {
48 id.rsplit('/').next().unwrap_or(id).to_string()
49}
50
51pub fn invocation_surfaces(graph: &RealityGraph) -> Vec<InvocationSurface> {
67 let mut out: Vec<InvocationSurface> = Vec::new();
68 let mut seen: HashSet<(InvocationSurfaceKind, String)> = HashSet::new();
71
72 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 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 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 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 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 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 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 for t in graph.entities_of_kind(kinds::TOPIC) {
228 let mut handlers: BTreeMap<String, String> = BTreeMap::new(); 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 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 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 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 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 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
403const 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
422fn 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
446const PUBLIC_API_CHAIN_SEED_CAP: usize = 48;
458const PUBLIC_API_LEAF_SEED_CAP: usize = 16;
459
460fn 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
471pub 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 let mut seen: HashSet<String> = HashSet::new();
484 let flow_key = |name: &str| entity_id(&store.repo_id, kinds::FLOW, name);
485
486 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 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 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 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 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
638pub(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 .filter(|r| matches!(r.provenance, Provenance::Extracted | Provenance::Resolved))
673 .map(|r| (prov_rank(r.provenance), r.object.clone()))
674 .collect();
675 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; }
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
704pub 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 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 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 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 continue;
797 }
798 let paths = walk_calls(graph, &ep.symbol_id);
799 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 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 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 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 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 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 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 let arch = compile_architecture(graph, store);
997
998 Ok((sequences, dataflows, arch))
999}
1000
1001fn 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
1270 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 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 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 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 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 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 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 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 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 assert!(
1446 paths[0][1] == mk("resolved_step"),
1447 "RESOLVED targets expand before EXTRACTED ones: {paths:?}"
1448 );
1449 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 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 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 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 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 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 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}