1use std::collections::BTreeMap;
48use std::collections::HashMap;
49use std::collections::HashSet;
50
51use serde::Serialize;
52
53use crate::graph::unified::concurrent::GraphSnapshot;
54use crate::graph::unified::edge::EdgeKind;
55use crate::graph::unified::node::NodeId;
56use crate::graph::unified::node::kind::NodeKind;
57
58use super::subgraph::{EdgeRetention, SeededSubgraph, SeededSubgraphConfig, build_seeded_subgraph};
59
60pub const ARCHIFY_SCHEMA_VERSION: u8 = 1;
62
63pub const DEFAULT_MAX_COMPONENTS: usize = 40;
65
66pub const GRID_COL_CAP: usize = 12;
68
69#[derive(Debug, thiserror::Error)]
76pub enum ArchifyError {
77 #[error("archify export produced no components (empty or filtered subgraph)")]
80 NoComponents,
81
82 #[error("archify export produced no summary cards")]
84 NoCards,
85
86 #[error("component id {0:?} does not match the Archify id pattern")]
88 InvalidComponentId(String),
89
90 #[error("dangling id reference {0:?} (not a defined component)")]
93 DanglingReference(String),
94
95 #[error("boundary {0:?} wraps no components")]
97 EmptyBoundary(String),
98
99 #[error("grid row {row} has width {width} exceeding the {cap}-column cap")]
102 RowTooWide {
103 row: usize,
105 width: usize,
107 cap: usize,
109 },
110
111 #[error("archify JSON serialization failed: {0}")]
113 Serialize(#[from] serde_json::Error),
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
122#[serde(rename_all = "lowercase")]
123pub enum ComponentType {
124 Frontend,
126 Backend,
128 Database,
130 Cloud,
132 Security,
134 Messagebus,
136 External,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
142#[serde(rename_all = "lowercase")]
143pub enum Variant {
144 Default,
146 Emphasis,
148 Security,
150 Dashed,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
156#[serde(rename_all = "lowercase")]
157pub enum CardDot {
158 Cyan,
160 Emerald,
162 Violet,
164 Amber,
166 Rose,
168 Orange,
170 Slate,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
176#[serde(rename_all = "kebab-case")]
177pub enum BoundaryKind {
178 Region,
180 SecurityGroup,
182}
183
184#[derive(Debug, Clone, Serialize)]
186pub struct ArchifyDocument {
187 pub schema_version: u8,
189 pub diagram_type: &'static str,
191 pub meta: ArchifyMeta,
193 pub layout: ArchifyLayout,
195 pub components: Vec<ArchifyComponent>,
197 #[serde(skip_serializing_if = "Vec::is_empty")]
199 pub boundaries: Vec<ArchifyBoundary>,
200 #[serde(skip_serializing_if = "Vec::is_empty")]
202 pub connections: Vec<ArchifyConnection>,
203 pub cards: Vec<ArchifyCard>,
205}
206
207#[derive(Debug, Clone, Serialize)]
209pub struct ArchifyMeta {
210 pub title: String,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub subtitle: Option<String>,
215}
216
217#[derive(Debug, Clone, Serialize)]
219pub struct ArchifyLayout {
220 pub mode: &'static str,
222 pub cols: usize,
224}
225
226#[derive(Debug, Clone, Serialize)]
228pub struct ArchifyComponent {
229 pub id: String,
231 #[serde(rename = "type")]
233 pub component_type: ComponentType,
234 pub label: String,
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub sublabel: Option<String>,
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub tag: Option<String>,
242 pub row: usize,
244 pub col: usize,
246}
247
248#[derive(Debug, Clone, Serialize)]
250pub struct ArchifyBoundary {
251 pub kind: BoundaryKind,
253 pub label: String,
255 pub wraps: Vec<String>,
257}
258
259#[derive(Debug, Clone, Serialize)]
261pub struct ArchifyConnection {
262 pub from: String,
264 pub to: String,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub label: Option<String>,
269 pub variant: Variant,
271}
272
273#[derive(Debug, Clone, Serialize)]
275pub struct ArchifyCard {
276 pub dot: CardDot,
278 pub title: String,
280 pub items: Vec<String>,
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
290pub enum ArchifyEdgeClass {
291 Http,
293 Grpc,
295 Db,
297 Mq,
299 Ws,
301 Ffi,
303 Calls,
305 Import,
307 Export,
309}
310
311impl ArchifyEdgeClass {
312 const fn priority(self) -> u8 {
315 match self {
316 Self::Http => 0,
317 Self::Grpc => 1,
318 Self::Db => 2,
319 Self::Mq => 3,
320 Self::Ws => 4,
321 Self::Ffi => 5,
322 Self::Calls => 6,
323 Self::Import => 7,
324 Self::Export => 8,
325 }
326 }
327
328 const fn is_semantic(self) -> bool {
331 matches!(
332 self,
333 Self::Http | Self::Grpc | Self::Db | Self::Mq | Self::Ws | Self::Ffi
334 )
335 }
336
337 const fn label_variant(self) -> (&'static str, Variant) {
339 match self {
340 Self::Http => ("HTTP", Variant::Emphasis),
341 Self::Grpc => ("gRPC", Variant::Dashed),
342 Self::Db => ("SQL", Variant::Default),
343 Self::Mq => ("queue", Variant::Emphasis),
344 Self::Ws => ("ws", Variant::Emphasis),
345 Self::Ffi => ("FFI", Variant::Dashed),
346 Self::Calls => ("calls", Variant::Default),
347 Self::Import => ("imports", Variant::Dashed),
348 Self::Export => ("exports", Variant::Dashed),
349 }
350 }
351}
352
353#[must_use]
360pub fn archify_classify_edge(kind: &EdgeKind) -> Option<EdgeRetention<ArchifyEdgeClass>> {
361 let (class, traverse) = match kind {
362 EdgeKind::Calls { .. } => (ArchifyEdgeClass::Calls, true),
363 EdgeKind::Imports { .. } => (ArchifyEdgeClass::Import, true),
364 EdgeKind::Exports { .. } => (ArchifyEdgeClass::Export, false),
365 EdgeKind::HttpRequest { .. } => (ArchifyEdgeClass::Http, true),
366 EdgeKind::GrpcCall { .. } => (ArchifyEdgeClass::Grpc, true),
367 EdgeKind::DbQuery { .. } | EdgeKind::TableRead { .. } | EdgeKind::TableWrite { .. } => {
368 (ArchifyEdgeClass::Db, true)
369 }
370 EdgeKind::MessageQueue { .. } => (ArchifyEdgeClass::Mq, true),
371 EdgeKind::WebSocket { .. } => (ArchifyEdgeClass::Ws, true),
372 EdgeKind::FfiCall { .. } => (ArchifyEdgeClass::Ffi, true),
373 _ => return None,
374 };
375 Some(EdgeRetention { class, traverse })
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384enum Confidence {
385 High,
387 Medium,
389 Fallback,
391}
392
393impl Confidence {
394 const fn is_high(self) -> bool {
395 matches!(self, Self::High)
396 }
397}
398
399#[derive(Debug, Clone)]
405pub struct ArchifyConfig {
406 pub seed_label: String,
408 pub title: String,
410 pub max_depth: usize,
413 pub max_components: usize,
415}
416
417impl Default for ArchifyConfig {
418 fn default() -> Self {
419 Self {
420 seed_label: String::new(),
421 title: String::new(),
422 max_depth: super::subgraph::DEFAULT_MAX_DEPTH,
423 max_components: DEFAULT_MAX_COMPONENTS,
424 }
425 }
426}
427
428pub fn build_archify_document(
444 snapshot: &GraphSnapshot,
445 seeds: &[NodeId],
446 subgraph_config: &SeededSubgraphConfig,
447 config: &ArchifyConfig,
448) -> Result<ArchifyDocument, ArchifyError> {
449 Ok(build_archify_document_with_meta(snapshot, seeds, subgraph_config, config)?.document)
450}
451
452#[derive(Debug, Clone)]
457pub struct ArchifyBuildMeta {
458 pub document: ArchifyDocument,
460 pub truncated: bool,
464 pub total_edges: usize,
468}
469
470pub fn build_archify_document_with_meta(
479 snapshot: &GraphSnapshot,
480 seeds: &[NodeId],
481 subgraph_config: &SeededSubgraphConfig,
482 config: &ArchifyConfig,
483) -> Result<ArchifyBuildMeta, ArchifyError> {
484 let subgraph = build_seeded_subgraph(snapshot, seeds, subgraph_config, archify_classify_edge);
485 let truncated = subgraph.truncated;
486 let total_edges = subgraph.edges.len();
487 let document = ArchifyBuilder {
488 snapshot,
489 subgraph: &subgraph,
490 config,
491 }
492 .build()?;
493 Ok(ArchifyBuildMeta {
494 document,
495 truncated,
496 total_edges,
497 })
498}
499
500pub fn export_archify_json(
507 snapshot: &GraphSnapshot,
508 seeds: &[NodeId],
509 subgraph_config: &SeededSubgraphConfig,
510 config: &ArchifyConfig,
511) -> Result<String, ArchifyError> {
512 let doc = build_archify_document(snapshot, seeds, subgraph_config, config)?;
513 Ok(serde_json::to_string_pretty(&doc)?)
514}
515
516struct DraftComponent {
522 cluster_key: String,
524 id: String,
526 label: String,
527 package: String,
528 language: String,
529 node_count: usize,
530 component_type: ComponentType,
531 confidence: Confidence,
532 basis: String,
533 bfs_rank: usize,
535 grid: (usize, usize),
537}
538
539struct ArchifyBuilder<'a> {
540 snapshot: &'a GraphSnapshot,
541 subgraph: &'a SeededSubgraph<ArchifyEdgeClass>,
542 config: &'a ArchifyConfig,
543}
544
545impl ArchifyBuilder<'_> {
546 fn build(&self) -> Result<ArchifyDocument, ArchifyError> {
547 if self.subgraph.nodes.is_empty() {
548 return Err(ArchifyError::NoComponents);
549 }
550
551 let semantic_targets = self.semantic_target_set();
553 let (node_to_cluster, mut drafts) = self.cluster_nodes(&semantic_targets);
554
555 if drafts.is_empty() {
556 return Err(ArchifyError::NoComponents);
557 }
558
559 self.infer_types(&node_to_cluster, &mut drafts);
561
562 assign_ids(&mut drafts);
564
565 let mut connections = self.aggregate_connections(&node_to_cluster, &drafts);
567
568 let low_degree_elided =
570 elide_components(&mut drafts, &connections, self.config.max_components);
571
572 let (cols, grid_cap_elided) = place_grid(&mut drafts)?;
577 let elided = low_degree_elided + grid_cap_elided;
578
579 let live_ids: HashSet<&str> = drafts.iter().map(|d| d.id.as_str()).collect();
581 connections
582 .retain(|c| live_ids.contains(c.from.as_str()) && live_ids.contains(c.to.as_str()));
583
584 let boundaries = build_boundaries(&drafts);
586
587 let languages = self.languages_present();
589 let cards = build_cards(&drafts, &connections, self.config, &languages, elided);
590
591 let components: Vec<ArchifyComponent> = drafts.iter().map(DraftComponent::finish).collect();
593
594 let title = if self.config.title.trim().is_empty() {
595 if self.config.seed_label.trim().is_empty() {
596 "Architecture".to_string()
597 } else {
598 format!("Architecture: {}", self.config.seed_label)
599 }
600 } else {
601 self.config.title.clone()
602 };
603
604 let doc = ArchifyDocument {
605 schema_version: ARCHIFY_SCHEMA_VERSION,
606 diagram_type: "architecture",
607 meta: ArchifyMeta {
608 title,
609 subtitle: None,
610 },
611 layout: ArchifyLayout { mode: "grid", cols },
612 components,
613 boundaries,
614 connections,
615 cards,
616 };
617
618 self_validate(&doc)?;
619 Ok(doc)
620 }
621
622 fn semantic_target_set(&self) -> HashSet<NodeId> {
624 self.subgraph
625 .edges
626 .iter()
627 .filter(|e| e.class.is_semantic())
628 .map(|e| e.to)
629 .collect()
630 }
631
632 fn cluster_nodes(
637 &self,
638 semantic_targets: &HashSet<NodeId>,
639 ) -> (HashMap<NodeId, usize>, Vec<DraftComponent>) {
640 let mut node_to_cluster: HashMap<NodeId, usize> = HashMap::new();
641 let mut key_to_index: HashMap<String, usize> = HashMap::new();
642 let mut drafts: Vec<DraftComponent> = Vec::new();
643
644 for (rank, &node) in self.subgraph.nodes.iter().enumerate() {
645 let Some(entry) = self.snapshot.get_node(node) else {
646 continue;
647 };
648 let is_anchor = is_anchor_kind(entry.kind) || semantic_targets.contains(&node);
649 let (cluster_key, label) = if is_anchor {
650 (
651 format!("anchor:{}", node.index()),
652 self.node_short_name(node),
653 )
654 } else {
655 let file = self.node_file(node);
656 (format!("file:{file}"), file_basename(&file))
657 };
658
659 let idx = *key_to_index.entry(cluster_key.clone()).or_insert_with(|| {
660 let file = self.node_file(node);
661 drafts.push(DraftComponent {
662 cluster_key: cluster_key.clone(),
663 id: String::new(),
664 label,
665 package: package_of(&file),
666 language: self.node_language(node),
667 node_count: 0,
668 component_type: ComponentType::Backend,
669 confidence: Confidence::Fallback,
670 basis: "internal callable cluster (no other signal)".to_string(),
671 bfs_rank: rank,
672 grid: (0, 0),
673 });
674 drafts.len() - 1
675 });
676
677 let draft = &mut drafts[idx];
678 draft.node_count += 1;
679 draft.bfs_rank = draft.bfs_rank.min(rank);
680 node_to_cluster.insert(node, idx);
681 }
682
683 (node_to_cluster, drafts)
684 }
685
686 fn infer_types(&self, node_to_cluster: &HashMap<NodeId, usize>, drafts: &mut [DraftComponent]) {
688 let n = drafts.len();
690 let mut has_frontend = vec![false; n];
691 let mut has_endpoint = vec![false; n];
692 let mut is_http_target = vec![false; n];
693 let mut is_db_target = vec![false; n];
694 let mut is_mq_target = vec![false; n];
695 let mut is_external_target = vec![false; n];
696
697 for (&node, &idx) in node_to_cluster {
699 if let Some(entry) = self.snapshot.get_node(node) {
700 match entry.kind {
701 NodeKind::Component => has_frontend[idx] = true,
702 NodeKind::Endpoint => has_endpoint[idx] = true,
703 _ => {}
704 }
705 }
706 }
707
708 for edge in &self.subgraph.edges {
710 let Some(&idx) = node_to_cluster.get(&edge.to) else {
711 continue;
712 };
713 match edge.class {
714 ArchifyEdgeClass::Http => is_http_target[idx] = true,
715 ArchifyEdgeClass::Db => is_db_target[idx] = true,
716 ArchifyEdgeClass::Mq | ArchifyEdgeClass::Ws => is_mq_target[idx] = true,
717 ArchifyEdgeClass::Grpc | ArchifyEdgeClass::Ffi => is_external_target[idx] = true,
718 _ => {}
719 }
720 }
721
722 for (idx, draft) in drafts.iter_mut().enumerate() {
723 let (ty, conf, basis) = if has_frontend[idx] {
725 (
726 ComponentType::Frontend,
727 Confidence::High,
728 "UI component node".to_string(),
729 )
730 } else if has_endpoint[idx] || is_http_target[idx] {
731 (
732 ComponentType::Backend,
733 Confidence::High,
734 if has_endpoint[idx] {
735 "HTTP endpoint node".to_string()
736 } else {
737 "target of HTTP request edges".to_string()
738 },
739 )
740 } else if is_db_target[idx] {
741 (
742 ComponentType::Database,
743 Confidence::High,
744 "target of database query edges".to_string(),
745 )
746 } else if is_mq_target[idx] {
747 (
748 ComponentType::Messagebus,
749 Confidence::High,
750 "target of message-queue / websocket edges".to_string(),
751 )
752 } else if is_external_target[idx] {
753 (
754 ComponentType::External,
755 Confidence::Medium,
756 "target of gRPC / FFI edges".to_string(),
757 )
758 } else {
759 (
760 ComponentType::Backend,
761 Confidence::Fallback,
762 "internal callable cluster (no other signal)".to_string(),
763 )
764 };
765 draft.component_type = ty;
766 draft.confidence = conf;
767 draft.basis = basis;
768 }
769 }
770
771 fn aggregate_connections(
773 &self,
774 node_to_cluster: &HashMap<NodeId, usize>,
775 drafts: &[DraftComponent],
776 ) -> Vec<ArchifyConnection> {
777 let mut agg: BTreeMap<(usize, usize), HashMap<ArchifyEdgeClass, usize>> = BTreeMap::new();
779 for edge in &self.subgraph.edges {
780 let (Some(&from_idx), Some(&to_idx)) = (
781 node_to_cluster.get(&edge.from),
782 node_to_cluster.get(&edge.to),
783 ) else {
784 continue;
785 };
786 if from_idx == to_idx {
787 continue; }
789 *agg.entry((from_idx, to_idx))
790 .or_default()
791 .entry(edge.class)
792 .or_insert(0) += 1;
793 }
794
795 let mut connections: Vec<ArchifyConnection> = agg
796 .into_iter()
797 .map(|((from_idx, to_idx), classes)| {
798 let dominant = dominant_class(&classes);
799 let (label, variant) = dominant.label_variant();
800 ArchifyConnection {
801 from: drafts[from_idx].id.clone(),
802 to: drafts[to_idx].id.clone(),
803 label: Some(label.to_string()),
804 variant,
805 }
806 })
807 .collect();
808
809 connections.sort_by(|a, b| a.from.cmp(&b.from).then_with(|| a.to.cmp(&b.to)));
811 connections
812 }
813
814 fn languages_present(&self) -> Vec<String> {
816 let mut langs: HashSet<String> = HashSet::new();
817 for &node in &self.subgraph.nodes {
818 langs.insert(self.node_language(node));
819 }
820 let mut langs: Vec<String> = langs.into_iter().collect();
821 langs.sort();
822 langs
823 }
824
825 fn node_short_name(&self, node: NodeId) -> String {
828 self.snapshot
829 .get_node(node)
830 .and_then(|e| self.snapshot.strings().resolve(e.name))
831 .map_or_else(|| format!("node{}", node.index()), |s| s.to_string())
832 }
833
834 fn node_file(&self, node: NodeId) -> String {
835 self.snapshot
836 .get_node(node)
837 .and_then(|e| self.snapshot.files().resolve(e.file))
838 .map_or_else(String::new, |p| p.to_string_lossy().replace('\\', "/"))
839 }
840
841 fn node_language(&self, node: NodeId) -> String {
842 self.snapshot
843 .get_node(node)
844 .and_then(|e| self.snapshot.files().language_for_file(e.file))
845 .map_or_else(|| "unknown".to_string(), |l| l.to_string())
846 }
847}
848
849impl DraftComponent {
850 fn finish(&self) -> ArchifyComponent {
851 let mut sublabel = format!("{} · {} sym", self.language, self.node_count);
852 let tag = if self.confidence.is_high() {
853 None
854 } else {
855 sublabel = format!("{sublabel} · inferred: {}", self.basis);
857 Some("inferred".to_string())
858 };
859 ArchifyComponent {
860 id: self.id.clone(),
861 component_type: self.component_type,
862 label: self.label.clone(),
863 sublabel: Some(sublabel),
864 tag,
865 row: self.grid.0,
866 col: self.grid.1,
867 }
868 }
869}
870
871const fn is_anchor_kind(kind: NodeKind) -> bool {
876 matches!(
877 kind,
878 NodeKind::Endpoint
879 | NodeKind::Service
880 | NodeKind::Resource
881 | NodeKind::Channel
882 | NodeKind::Component
883 )
884}
885
886fn dominant_class(classes: &HashMap<ArchifyEdgeClass, usize>) -> ArchifyEdgeClass {
888 classes
889 .iter()
890 .max_by(|a, b| {
891 a.1.cmp(b.1)
892 .then_with(|| b.0.priority().cmp(&a.0.priority()))
893 })
894 .map_or(ArchifyEdgeClass::Calls, |(&class, _)| class)
895}
896
897fn package_of(file: &str) -> String {
900 match file.rsplit_once('/') {
901 Some((dir, _)) if !dir.is_empty() => dir.to_string(),
902 _ => "(root)".to_string(),
903 }
904}
905
906fn file_basename(file: &str) -> String {
908 file.rsplit_once('/')
909 .map_or_else(|| file.to_string(), |(_, base)| base.to_string())
910}
911
912fn sanitize_id(raw: &str) -> String {
917 let mut out = String::with_capacity(raw.len() + 1);
918 let mut last_dash = false;
919 for ch in raw.chars() {
920 if ch.is_ascii_alphanumeric() || ch == '_' {
921 out.push(ch);
922 last_dash = false;
923 } else if !last_dash {
924 out.push('-');
925 last_dash = true;
926 }
927 }
928 let trimmed = out.trim_matches('-');
929 let mut result = trimmed.to_string();
930 if result.is_empty()
931 || !result
932 .chars()
933 .next()
934 .is_some_and(|c| c.is_ascii_alphabetic())
935 {
936 result = format!("c{result}");
937 }
938 result
939}
940
941fn assign_ids(drafts: &mut [DraftComponent]) {
944 let mut order: Vec<usize> = (0..drafts.len()).collect();
945 order.sort_by(|&a, &b| drafts[a].cluster_key.cmp(&drafts[b].cluster_key));
946
947 let mut seen: HashMap<String, u32> = HashMap::new();
948 for &i in &order {
949 let base = sanitize_id(&drafts[i].label);
950 let id = match seen.get_mut(&base) {
951 Some(count) => {
952 *count += 1;
953 format!("{base}-{count}")
954 }
955 None => {
956 seen.insert(base.clone(), 0);
957 base
958 }
959 };
960 drafts[i].id = id;
961 }
962}
963
964fn elide_components(
967 drafts: &mut Vec<DraftComponent>,
968 connections: &[ArchifyConnection],
969 max_components: usize,
970) -> usize {
971 if drafts.len() <= max_components {
972 return 0;
973 }
974 let degree = component_degrees(drafts, connections);
975
976 let mut order: Vec<usize> = (0..drafts.len()).collect();
978 order.sort_by(|&a, &b| {
979 degree[b]
980 .cmp(°ree[a])
981 .then_with(|| drafts[a].id.cmp(&drafts[b].id))
982 });
983 let keep: HashSet<usize> = order.into_iter().take(max_components).collect();
984
985 let elided = drafts.len() - keep.len();
986 let mut idx = 0;
987 drafts.retain(|_| {
988 let keep_this = keep.contains(&idx);
989 idx += 1;
990 keep_this
991 });
992 elided
993}
994
995fn component_degrees(drafts: &[DraftComponent], connections: &[ArchifyConnection]) -> Vec<usize> {
997 let id_to_idx: HashMap<&str, usize> = drafts
998 .iter()
999 .enumerate()
1000 .map(|(i, d)| (d.id.as_str(), i))
1001 .collect();
1002 let mut degree = vec![0usize; drafts.len()];
1003 for c in connections {
1004 if let Some(&i) = id_to_idx.get(c.from.as_str()) {
1005 degree[i] += 1;
1006 }
1007 if let Some(&i) = id_to_idx.get(c.to.as_str()) {
1008 degree[i] += 1;
1009 }
1010 }
1011 degree
1012}
1013
1014fn place_grid(drafts: &mut Vec<DraftComponent>) -> Result<(usize, usize), ArchifyError> {
1023 let mut rows: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
1026 for (i, d) in drafts.iter().enumerate() {
1027 rows.entry(row_for_type(d.component_type))
1028 .or_default()
1029 .push(i);
1030 }
1031
1032 let mut drop: HashSet<usize> = HashSet::new();
1035 let mut placement: HashMap<usize, (usize, usize)> = HashMap::new();
1036 for (&row, indices) in &rows {
1037 let mut ordered = indices.clone();
1038 ordered.sort_by(|&a, &b| {
1039 drafts[a]
1040 .bfs_rank
1041 .cmp(&drafts[b].bfs_rank)
1042 .then_with(|| drafts[a].id.cmp(&drafts[b].id))
1043 });
1044 for (col, &i) in ordered.iter().enumerate() {
1045 if col >= GRID_COL_CAP {
1046 drop.insert(i);
1047 } else {
1048 placement.insert(i, (row, col));
1049 }
1050 }
1051 }
1052
1053 if !drop.is_empty() {
1054 let dropped_here = drop.len();
1055 let mut idx = 0;
1056 drafts.retain(|_| {
1057 let keep = !drop.contains(&idx);
1058 idx += 1;
1059 keep
1060 });
1061 let (cols, dropped_rest) = place_grid(drafts)?;
1064 return Ok((cols, dropped_here + dropped_rest));
1065 }
1066
1067 let mut max_width = 1usize;
1068 for (i, d) in drafts.iter_mut().enumerate() {
1069 let (row, col) = placement.get(&i).copied().unwrap_or((0, 0));
1070 d.set_grid(row, col);
1071 max_width = max_width.max(col + 1);
1072 }
1073
1074 let mut widths: BTreeMap<usize, usize> = BTreeMap::new();
1076 for d in drafts.iter() {
1077 *widths.entry(d.grid_row()).or_insert(0) += 1;
1078 }
1079 for (&row, &width) in &widths {
1080 if width > GRID_COL_CAP {
1081 return Err(ArchifyError::RowTooWide {
1082 row,
1083 width,
1084 cap: GRID_COL_CAP,
1085 });
1086 }
1087 }
1088
1089 Ok((max_width.clamp(1, GRID_COL_CAP), 0))
1090}
1091
1092const fn row_for_type(ty: ComponentType) -> usize {
1094 match ty {
1095 ComponentType::Frontend => 0,
1096 ComponentType::External | ComponentType::Security => 1,
1097 ComponentType::Backend => 2,
1098 ComponentType::Messagebus => 3,
1099 ComponentType::Database => 4,
1100 ComponentType::Cloud => 5,
1101 }
1102}
1103
1104fn build_boundaries(drafts: &[DraftComponent]) -> Vec<ArchifyBoundary> {
1106 let mut by_package: BTreeMap<String, Vec<String>> = BTreeMap::new();
1107 for d in drafts {
1108 by_package
1109 .entry(d.package.clone())
1110 .or_default()
1111 .push(d.id.clone());
1112 }
1113 by_package
1114 .into_iter()
1115 .map(|(label, mut wraps)| {
1116 wraps.sort();
1117 ArchifyBoundary {
1118 kind: BoundaryKind::Region,
1119 label,
1120 wraps,
1121 }
1122 })
1123 .collect()
1124}
1125
1126fn build_cards(
1128 drafts: &[DraftComponent],
1129 connections: &[ArchifyConnection],
1130 config: &ArchifyConfig,
1131 languages: &[String],
1132 elided: usize,
1133) -> Vec<ArchifyCard> {
1134 let mut cards = Vec::new();
1135
1136 let seed = if config.seed_label.trim().is_empty() {
1137 "(unspecified)".to_string()
1138 } else {
1139 config.seed_label.clone()
1140 };
1141 cards.push(ArchifyCard {
1142 dot: CardDot::Cyan,
1143 title: "Overview".to_string(),
1144 items: vec![
1145 format!("Seed: {seed}"),
1146 format!("Depth: {}", config.max_depth),
1147 format!("Components: {}", drafts.len()),
1148 format!("Connections: {}", connections.len()),
1149 format!("Languages: {}", languages.join(", ")),
1150 ],
1151 });
1152
1153 let inferred: Vec<String> = drafts
1155 .iter()
1156 .filter(|d| !d.confidence.is_high())
1157 .map(|d| {
1158 format!(
1159 "{}: {} inferred from {}",
1160 d.label,
1161 component_type_str(d.component_type),
1162 d.basis
1163 )
1164 })
1165 .collect();
1166 if !inferred.is_empty() {
1167 cards.push(ArchifyCard {
1168 dot: CardDot::Violet,
1169 title: "Inferred tiers".to_string(),
1170 items: inferred,
1171 });
1172 }
1173
1174 let mut caveats = vec![
1176 "Seeded subgraph, not the whole repository.".to_string(),
1177 format!(
1178 "Limits: max_depth={}, max_components={}.",
1179 config.max_depth, config.max_components
1180 ),
1181 ];
1182 if elided > 0 {
1183 caveats.push(format!(
1184 "Elided {elided} component(s) for readability (low-degree pruning and/or grid-width limits)."
1185 ));
1186 }
1187 caveats.push(
1188 "sqry supplies code-grounded facts; Archify handles layout and presentation.".to_string(),
1189 );
1190 cards.push(ArchifyCard {
1191 dot: CardDot::Amber,
1192 title: "Caveats".to_string(),
1193 items: caveats,
1194 });
1195
1196 cards
1197}
1198
1199const fn component_type_str(ty: ComponentType) -> &'static str {
1200 match ty {
1201 ComponentType::Frontend => "frontend",
1202 ComponentType::Backend => "backend",
1203 ComponentType::Database => "database",
1204 ComponentType::Cloud => "cloud",
1205 ComponentType::Security => "security",
1206 ComponentType::Messagebus => "messagebus",
1207 ComponentType::External => "external",
1208 }
1209}
1210
1211fn is_valid_id(id: &str) -> bool {
1213 let mut chars = id.chars();
1214 match chars.next() {
1215 Some(c) if c.is_ascii_alphabetic() => {}
1216 _ => return false,
1217 }
1218 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1219}
1220
1221fn self_validate(doc: &ArchifyDocument) -> Result<(), ArchifyError> {
1223 if doc.components.is_empty() {
1224 return Err(ArchifyError::NoComponents);
1225 }
1226 if doc.cards.is_empty() {
1227 return Err(ArchifyError::NoCards);
1228 }
1229
1230 let ids: HashSet<&str> = doc.components.iter().map(|c| c.id.as_str()).collect();
1231
1232 for c in &doc.components {
1233 if !is_valid_id(&c.id) {
1234 return Err(ArchifyError::InvalidComponentId(c.id.clone()));
1235 }
1236 }
1237 for conn in &doc.connections {
1238 if !ids.contains(conn.from.as_str()) {
1239 return Err(ArchifyError::DanglingReference(conn.from.clone()));
1240 }
1241 if !ids.contains(conn.to.as_str()) {
1242 return Err(ArchifyError::DanglingReference(conn.to.clone()));
1243 }
1244 }
1245 for b in &doc.boundaries {
1246 if b.wraps.is_empty() {
1247 return Err(ArchifyError::EmptyBoundary(b.label.clone()));
1248 }
1249 for w in &b.wraps {
1250 if !ids.contains(w.as_str()) {
1251 return Err(ArchifyError::DanglingReference(w.clone()));
1252 }
1253 }
1254 }
1255
1256 let mut widths: BTreeMap<usize, usize> = BTreeMap::new();
1258 for c in &doc.components {
1259 *widths.entry(c.row).or_insert(0) += 1;
1260 }
1261 for (&row, &width) in &widths {
1262 if width > GRID_COL_CAP {
1263 return Err(ArchifyError::RowTooWide {
1264 row,
1265 width,
1266 cap: GRID_COL_CAP,
1267 });
1268 }
1269 }
1270
1271 Ok(())
1272}
1273
1274impl DraftComponent {
1275 fn set_grid(&mut self, row: usize, col: usize) {
1276 self.grid = (row, col);
1277 }
1278 const fn grid_row(&self) -> usize {
1279 self.grid.0
1280 }
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285 use super::*;
1286 use crate::graph::node::Language;
1287 use crate::graph::unified::concurrent::CodeGraph;
1288 use crate::graph::unified::edge::{
1289 BidirectionalEdgeStore, DbQueryType, EdgeKind, HttpMethod, ResolvedVia,
1290 };
1291 use crate::graph::unified::node::NodeId;
1292 use crate::graph::unified::storage::NodeEntry;
1293 use crate::graph::unified::storage::arena::NodeArena;
1294 use crate::graph::unified::storage::indices::AuxiliaryIndices;
1295 use crate::graph::unified::storage::interner::StringInterner;
1296 use crate::graph::unified::storage::registry::FileRegistry;
1297 use std::path::Path;
1298
1299 #[allow(clippy::too_many_arguments)]
1301 fn node(
1302 arena: &mut NodeArena,
1303 strings: &mut StringInterner,
1304 kind: NodeKind,
1305 name: &str,
1306 qname: &str,
1307 file: crate::graph::unified::FileId,
1308 start_byte: u32,
1309 ) -> NodeId {
1310 let name_id = strings.intern(name).unwrap();
1311 let qname_id = strings.intern(qname).unwrap();
1312 arena
1313 .alloc(NodeEntry {
1314 kind,
1315 name: name_id,
1316 file,
1317 start_byte,
1318 end_byte: start_byte + 50,
1319 start_line: 1,
1320 start_column: 0,
1321 end_line: 5,
1322 end_column: 1,
1323 signature: None,
1324 doc: None,
1325 qualified_name: Some(qname_id),
1326 visibility: None,
1327 is_async: false,
1328 is_static: false,
1329 is_unsafe: false,
1330 is_definition: true,
1331 body_hash: None,
1332 })
1333 .unwrap()
1334 }
1335
1336 struct Fixture {
1337 graph: CodeGraph,
1338 dashboard: NodeId,
1339 }
1340
1341 fn build_fixture() -> Fixture {
1344 let mut arena = NodeArena::new();
1345 let mut strings = StringInterner::new();
1346 let mut files = FileRegistry::new();
1347 let edges = BidirectionalEdgeStore::new();
1348 let indices = AuxiliaryIndices::new();
1349
1350 let f_web = files
1351 .register_with_language(Path::new("web/app.jsx"), Some(Language::JavaScript))
1352 .unwrap();
1353 let f_routes = files
1354 .register_with_language(Path::new("api/routes.rs"), Some(Language::Rust))
1355 .unwrap();
1356 let f_handlers = files
1357 .register_with_language(Path::new("api/handlers.rs"), Some(Language::Rust))
1358 .unwrap();
1359 let f_queries = files
1360 .register_with_language(Path::new("db/queries.rs"), Some(Language::Rust))
1361 .unwrap();
1362 let f_schema = files
1363 .register_with_language(Path::new("db/schema.sql"), Some(Language::Sql))
1364 .unwrap();
1365
1366 let dashboard = node(
1367 &mut arena,
1368 &mut strings,
1369 NodeKind::Component,
1370 "Dashboard",
1371 "web::Dashboard",
1372 f_web,
1373 0,
1374 );
1375 let endpoint = node(
1376 &mut arena,
1377 &mut strings,
1378 NodeKind::Endpoint,
1379 "users_endpoint",
1380 "api::users_endpoint",
1381 f_routes,
1382 100,
1383 );
1384 let handler = node(
1385 &mut arena,
1386 &mut strings,
1387 NodeKind::Function,
1388 "handle_users",
1389 "api::handle_users",
1390 f_handlers,
1391 200,
1392 );
1393 let query_fn = node(
1394 &mut arena,
1395 &mut strings,
1396 NodeKind::Function,
1397 "run_query",
1398 "db::run_query",
1399 f_queries,
1400 300,
1401 );
1402 let table = node(
1403 &mut arena,
1404 &mut strings,
1405 NodeKind::Resource,
1406 "users",
1407 "db::users",
1408 f_schema,
1409 400,
1410 );
1411
1412 let url = strings.intern("/users").unwrap();
1413 let tbl = strings.intern("users").unwrap();
1414
1415 edges.add_edge(
1416 dashboard,
1417 endpoint,
1418 EdgeKind::HttpRequest {
1419 method: HttpMethod::Get,
1420 url: Some(url),
1421 },
1422 f_web,
1423 );
1424 edges.add_edge(
1425 endpoint,
1426 handler,
1427 EdgeKind::Calls {
1428 argument_count: 0,
1429 is_async: false,
1430 resolved_via: ResolvedVia::Direct,
1431 },
1432 f_routes,
1433 );
1434 edges.add_edge(
1435 handler,
1436 query_fn,
1437 EdgeKind::Calls {
1438 argument_count: 1,
1439 is_async: false,
1440 resolved_via: ResolvedVia::Direct,
1441 },
1442 f_handlers,
1443 );
1444 edges.add_edge(
1445 query_fn,
1446 table,
1447 EdgeKind::DbQuery {
1448 query_type: DbQueryType::Select,
1449 table: Some(tbl),
1450 },
1451 f_queries,
1452 );
1453
1454 let graph = CodeGraph::from_components(
1455 arena,
1456 edges,
1457 strings,
1458 files,
1459 indices,
1460 crate::graph::unified::NodeMetadataStore::new(),
1461 );
1462 Fixture { graph, dashboard }
1463 }
1464
1465 fn full_config() -> (SeededSubgraphConfig, ArchifyConfig) {
1466 let sub = SeededSubgraphConfig {
1467 max_depth: 5,
1468 max_results: 1000,
1469 languages: Vec::new(),
1470 }
1471 .normalized();
1472 let arch = ArchifyConfig {
1473 seed_label: "web::Dashboard".to_string(),
1474 title: String::new(),
1475 max_depth: sub.max_depth,
1476 max_components: DEFAULT_MAX_COMPONENTS,
1477 };
1478 (sub, arch)
1479 }
1480
1481 #[test]
1484 fn sanitize_id_matches_pattern() {
1485 for raw in [
1486 "crate::mod::Fn",
1487 "/api/users",
1488 "123file",
1489 "src/main.rs",
1490 "GET /users",
1491 "café::naïve",
1492 "",
1493 "---",
1494 "_leading",
1495 "a",
1496 ] {
1497 let id = sanitize_id(raw);
1498 assert!(
1499 is_valid_id(&id),
1500 "id {id:?} from {raw:?} is not schema-valid"
1501 );
1502 }
1503 }
1504
1505 #[test]
1506 fn sanitize_id_is_deterministic() {
1507 assert_eq!(sanitize_id("crate::mod::Fn"), sanitize_id("crate::mod::Fn"));
1508 assert_eq!(sanitize_id("a::b"), "a-b");
1509 assert_eq!(sanitize_id("123"), "c123");
1510 }
1511
1512 #[test]
1513 fn assign_ids_suffixes_collisions() {
1514 let mut drafts = vec![
1515 draft("file:a/x.rs", "x.rs"),
1516 draft("file:b/x.rs", "x.rs"),
1517 draft("file:c/x.rs", "x.rs"),
1518 ];
1519 assign_ids(&mut drafts);
1520 let ids: Vec<&str> = drafts.iter().map(|d| d.id.as_str()).collect();
1521 assert_eq!(ids, vec!["x-rs", "x-rs-1", "x-rs-2"]);
1523 }
1524
1525 fn draft(cluster_key: &str, label: &str) -> DraftComponent {
1526 DraftComponent {
1527 cluster_key: cluster_key.to_string(),
1528 id: String::new(),
1529 label: label.to_string(),
1530 package: "pkg".to_string(),
1531 language: "rust".to_string(),
1532 node_count: 1,
1533 component_type: ComponentType::Backend,
1534 confidence: Confidence::High,
1535 basis: String::new(),
1536 bfs_rank: 0,
1537 grid: (0, 0),
1538 }
1539 }
1540
1541 #[test]
1544 fn exports_expected_tiers_and_structure() {
1545 let fx = build_fixture();
1546 let (sub, arch) = full_config();
1547 let doc = build_archify_document(&fx.graph.snapshot(), &[fx.dashboard], &sub, &arch)
1548 .expect("archify doc");
1549
1550 let dashboard = doc
1552 .components
1553 .iter()
1554 .find(|c| c.label == "Dashboard")
1555 .expect("dashboard component");
1556 assert_eq!(dashboard.component_type, ComponentType::Frontend);
1557 assert!(dashboard.tag.is_none());
1558
1559 let endpoint = doc
1561 .components
1562 .iter()
1563 .find(|c| c.label == "users_endpoint")
1564 .expect("endpoint component");
1565 assert_eq!(endpoint.component_type, ComponentType::Backend);
1566
1567 let db = doc
1569 .components
1570 .iter()
1571 .find(|c| c.label == "users")
1572 .expect("db component");
1573 assert_eq!(db.component_type, ComponentType::Database);
1574
1575 assert!(!doc.cards.is_empty());
1577 assert!(doc.cards.iter().any(|c| c.title == "Overview"));
1578 assert!(
1579 doc.cards
1580 .iter()
1581 .flat_map(|c| &c.items)
1582 .any(|i| i.contains("Archify handles layout"))
1583 );
1584
1585 assert!(
1587 doc.connections
1588 .iter()
1589 .any(|c| matches!(c.variant, Variant::Emphasis)
1590 && c.label.as_deref() == Some("HTTP"))
1591 );
1592
1593 assert!(!doc.boundaries.is_empty());
1595 let ids: HashSet<&str> = doc.components.iter().map(|c| c.id.as_str()).collect();
1596 for b in &doc.boundaries {
1597 assert!(!b.wraps.is_empty());
1598 for w in &b.wraps {
1599 assert!(ids.contains(w.as_str()));
1600 }
1601 }
1602 }
1603
1604 #[test]
1605 fn no_cloud_or_security_inference_in_v1() {
1606 let mut arena = NodeArena::new();
1608 let mut strings = StringInterner::new();
1609 let mut files = FileRegistry::new();
1610 let edges = BidirectionalEdgeStore::new();
1611 let indices = AuxiliaryIndices::new();
1612 let f = files
1613 .register_with_language(Path::new("svc/auth.rs"), Some(Language::Rust))
1614 .unwrap();
1615 let jwt = node(
1616 &mut arena,
1617 &mut strings,
1618 NodeKind::Function,
1619 "JwtAuthClient",
1620 "svc::JwtAuthClient",
1621 f,
1622 0,
1623 );
1624 let s3 = node(
1625 &mut arena,
1626 &mut strings,
1627 NodeKind::Function,
1628 "S3Bucket",
1629 "svc::S3Bucket",
1630 f,
1631 100,
1632 );
1633 edges.add_edge(
1634 jwt,
1635 s3,
1636 EdgeKind::Calls {
1637 argument_count: 0,
1638 is_async: false,
1639 resolved_via: ResolvedVia::Direct,
1640 },
1641 f,
1642 );
1643 let graph = CodeGraph::from_components(
1644 arena,
1645 edges,
1646 strings,
1647 files,
1648 indices,
1649 crate::graph::unified::NodeMetadataStore::new(),
1650 );
1651 let (sub, mut arch) = full_config();
1652 arch.seed_label = "svc::JwtAuthClient".to_string();
1653 let doc = build_archify_document(&graph.snapshot(), &[jwt], &sub, &arch).unwrap();
1654 for c in &doc.components {
1655 assert_ne!(c.component_type, ComponentType::Cloud);
1656 assert_ne!(c.component_type, ComponentType::Security);
1657 }
1658 }
1659
1660 #[test]
1663 fn output_is_byte_identical_across_repeats() {
1664 let fx = build_fixture();
1665 let (sub, arch) = full_config();
1666 let a = export_archify_json(&fx.graph.snapshot(), &[fx.dashboard], &sub, &arch).unwrap();
1667 let b = export_archify_json(&fx.graph.snapshot(), &[fx.dashboard], &sub, &arch).unwrap();
1668 assert_eq!(a, b);
1669 }
1670
1671 #[test]
1672 fn output_is_stable_across_parallel_rebuilds() {
1673 let (sub, arch) = full_config();
1678 let outputs: Vec<String> = (0..8)
1679 .map(|_| {
1680 let fx = build_fixture();
1681 export_archify_json(&fx.graph.snapshot(), &[fx.dashboard], &sub, &arch).unwrap()
1682 })
1683 .collect();
1684 for o in &outputs[1..] {
1685 assert_eq!(&outputs[0], o);
1686 }
1687 }
1688
1689 #[test]
1692 fn respects_max_components_and_row_width() {
1693 let fx = build_fixture();
1694 let (sub, mut arch) = full_config();
1695 arch.max_components = 2;
1696 let doc = build_archify_document(&fx.graph.snapshot(), &[fx.dashboard], &sub, &arch)
1697 .expect("archify doc");
1698 assert!(doc.components.len() <= 2, "elided to max_components");
1699 let mut widths: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
1701 for c in &doc.components {
1702 *widths.entry(c.row).or_insert(0) += 1;
1703 }
1704 assert!(widths.values().all(|&w| w <= GRID_COL_CAP));
1705 assert!(
1707 doc.cards
1708 .iter()
1709 .flat_map(|c| &c.items)
1710 .any(|i| i.contains("Elided"))
1711 );
1712 }
1713
1714 #[test]
1723 fn grid_cap_drops_are_folded_into_elided_caveat_count() {
1724 let mut arena = NodeArena::new();
1725 let mut strings = StringInterner::new();
1726 let mut files = FileRegistry::new();
1727 let edges = BidirectionalEdgeStore::new();
1728 let indices = AuxiliaryIndices::new();
1729
1730 let f_web = files
1731 .register_with_language(Path::new("web/app.jsx"), Some(Language::JavaScript))
1732 .unwrap();
1733 let f_routes = files
1734 .register_with_language(Path::new("api/routes.rs"), Some(Language::Rust))
1735 .unwrap();
1736 let f_handlers = files
1737 .register_with_language(Path::new("api/handlers.rs"), Some(Language::Rust))
1738 .unwrap();
1739
1740 let dashboard = node(
1741 &mut arena,
1742 &mut strings,
1743 NodeKind::Component,
1744 "Dashboard",
1745 "web::Dashboard",
1746 f_web,
1747 0,
1748 );
1749 let endpoint = node(
1750 &mut arena,
1751 &mut strings,
1752 NodeKind::Endpoint,
1753 "users_endpoint",
1754 "api::users_endpoint",
1755 f_routes,
1756 100,
1757 );
1758 let handler = node(
1759 &mut arena,
1760 &mut strings,
1761 NodeKind::Function,
1762 "handle_users",
1763 "api::handle_users",
1764 f_handlers,
1765 200,
1766 );
1767
1768 let url = strings.intern("/users").unwrap();
1769 edges.add_edge(
1770 dashboard,
1771 endpoint,
1772 EdgeKind::HttpRequest {
1773 method: HttpMethod::Get,
1774 url: Some(url),
1775 },
1776 f_web,
1777 );
1778 edges.add_edge(
1779 endpoint,
1780 handler,
1781 EdgeKind::Calls {
1782 argument_count: 0,
1783 is_async: false,
1784 resolved_via: ResolvedVia::Direct,
1785 },
1786 f_routes,
1787 );
1788
1789 const HELPER_COUNT: usize = 15;
1797 for i in 0..HELPER_COUNT {
1798 let path = format!("svc/helper_{i}.rs");
1799 let f_helper = files
1800 .register_with_language(Path::new(&path), Some(Language::Rust))
1801 .unwrap();
1802 let name = format!("helper_{i}");
1803 let qname = format!("svc::helper_{i}");
1804 let helper = node(
1805 &mut arena,
1806 &mut strings,
1807 NodeKind::Function,
1808 &name,
1809 &qname,
1810 f_helper,
1811 300 + i as u32,
1812 );
1813 edges.add_edge(
1814 handler,
1815 helper,
1816 EdgeKind::Calls {
1817 argument_count: 0,
1818 is_async: false,
1819 resolved_via: ResolvedVia::Direct,
1820 },
1821 f_handlers,
1822 );
1823 }
1824
1825 let graph = CodeGraph::from_components(
1826 arena,
1827 edges,
1828 strings,
1829 files,
1830 indices,
1831 crate::graph::unified::NodeMetadataStore::new(),
1832 );
1833
1834 let (sub, mut arch) = full_config();
1835 arch.seed_label = "web::Dashboard".to_string();
1836 let doc = build_archify_document(&graph.snapshot(), &[dashboard], &sub, &arch)
1837 .expect("archify doc");
1838
1839 let mut widths: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
1841 for c in &doc.components {
1842 *widths.entry(c.row).or_insert(0) += 1;
1843 }
1844 assert!(widths.values().all(|&w| w <= GRID_COL_CAP));
1845
1846 let backend_row_candidates = 2 + HELPER_COUNT;
1850 let expected_grid_drops = backend_row_candidates - GRID_COL_CAP;
1851 assert!(
1852 expected_grid_drops > 0,
1853 "fixture must actually exceed the grid cap"
1854 );
1855
1856 let caveat_text = doc
1857 .cards
1858 .iter()
1859 .flat_map(|c| &c.items)
1860 .find(|i| i.starts_with("Elided "))
1861 .expect(
1862 "caveat card must report the grid-cap drops, not just elide_components' 0 count",
1863 );
1864 assert!(
1865 caveat_text.contains(&format!("Elided {expected_grid_drops} ")),
1866 "caveat must equal elide_components' drops (0 here) plus place_grid's grid-cap \
1867 drops (expected {expected_grid_drops}); got: {caveat_text}"
1868 );
1869 }
1870
1871 #[test]
1874 fn empty_subgraph_errors_closed() {
1875 let fx = build_fixture();
1876 let (sub, arch) = full_config();
1877 let err = build_archify_document(&fx.graph.snapshot(), &[], &sub, &arch).unwrap_err();
1879 assert!(matches!(err, ArchifyError::NoComponents));
1880 }
1881
1882 #[test]
1883 fn language_filter_to_nothing_errors_closed() {
1884 let fx = build_fixture();
1885 let arch = full_config().1;
1886 let sub = SeededSubgraphConfig {
1887 max_depth: 5,
1888 max_results: 1000,
1889 languages: vec![Language::Go],
1891 }
1892 .normalized();
1893 let err =
1894 build_archify_document(&fx.graph.snapshot(), &[fx.dashboard], &sub, &arch).unwrap_err();
1895 assert!(matches!(err, ArchifyError::NoComponents));
1896 }
1897
1898 struct CommonRetriever {
1901 common: serde_json::Value,
1902 }
1903
1904 impl jsonschema::Retrieve for CommonRetriever {
1905 fn retrieve(
1906 &self,
1907 uri: &jsonschema::Uri<String>,
1908 ) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
1909 if uri.as_str().ends_with("common.schema.json") {
1910 Ok(self.common.clone())
1911 } else {
1912 Err(format!("unexpected schema $ref: {uri}").into())
1913 }
1914 }
1915 }
1916
1917 fn architecture_validator() -> jsonschema::Validator {
1918 let base = concat!(
1919 env!("CARGO_MANIFEST_DIR"),
1920 "/../test-fixtures/archify-schema"
1921 );
1922 let arch: serde_json::Value = serde_json::from_str(
1923 &std::fs::read_to_string(format!("{base}/architecture.schema.json")).unwrap(),
1924 )
1925 .unwrap();
1926 let common: serde_json::Value = serde_json::from_str(
1927 &std::fs::read_to_string(format!("{base}/common.schema.json")).unwrap(),
1928 )
1929 .unwrap();
1930 jsonschema::options()
1931 .with_retriever(CommonRetriever { common })
1932 .build(&arch)
1933 .expect("build validator")
1934 }
1935
1936 #[test]
1937 fn generated_samples_validate_against_vendored_schema() {
1938 let validator = architecture_validator();
1939
1940 let fx = build_fixture();
1942 let (sub, arch) = full_config();
1943 let doc =
1944 build_archify_document(&fx.graph.snapshot(), &[fx.dashboard], &sub, &arch).unwrap();
1945 let value = serde_json::to_value(&doc).unwrap();
1946 assert!(
1947 validator.is_valid(&value),
1948 "polyglot sample failed schema: {:?}",
1949 validator
1950 .iter_errors(&value)
1951 .map(|e| e.to_string())
1952 .collect::<Vec<_>>()
1953 );
1954
1955 let mut arena = NodeArena::new();
1957 let mut strings = StringInterner::new();
1958 let mut files = FileRegistry::new();
1959 let edges = BidirectionalEdgeStore::new();
1960 let indices = AuxiliaryIndices::new();
1961 let f = files
1962 .register_with_language(Path::new("lib/solo.rs"), Some(Language::Rust))
1963 .unwrap();
1964 let solo = node(
1965 &mut arena,
1966 &mut strings,
1967 NodeKind::Function,
1968 "solo",
1969 "lib::solo",
1970 f,
1971 0,
1972 );
1973 let graph = CodeGraph::from_components(
1974 arena,
1975 edges,
1976 strings,
1977 files,
1978 indices,
1979 crate::graph::unified::NodeMetadataStore::new(),
1980 );
1981 let doc2 = build_archify_document(&graph.snapshot(), &[solo], &sub, &arch).unwrap();
1982 let value2 = serde_json::to_value(&doc2).unwrap();
1983 assert!(
1984 validator.is_valid(&value2),
1985 "solo sample failed schema: {:?}",
1986 validator
1987 .iter_errors(&value2)
1988 .map(|e| e.to_string())
1989 .collect::<Vec<_>>()
1990 );
1991
1992 let (sub3, mut arch3) = full_config();
1994 arch3.max_components = 2;
1995 let doc3 =
1996 build_archify_document(&fx.graph.snapshot(), &[fx.dashboard], &sub3, &arch3).unwrap();
1997 let value3 = serde_json::to_value(&doc3).unwrap();
1998 assert!(validator.is_valid(&value3), "elided sample failed schema");
1999 }
2000}