Skip to main content

sqry_core/visualization/
archify.rs

1//! Archify architecture-diagram exporter.
2//!
3//! Turns a seeded, depth-limited code subgraph into
4//! [Archify](https://github.com/tt-a1i/archify) `architecture` diagram JSON.
5//! sqry supplies the code-grounded facts (real symbols, kinds, files,
6//! languages, calls, HTTP/DB/queue edges); Archify owns the presentation layer
7//! (themed HTML/SVG rendering). This module is the bridge and adds no new graph
8//! facts, node kinds, or edge kinds.
9//!
10//! # What it emits
11//!
12//! A typed model (Section "Emission model") that mirrors Archify's schema
13//! exactly, so invalid diagram types, component types, card colours, or extra
14//! fields are unrepresentable. Symbols group into tier-typed `components` and
15//! package `boundaries`; retained edges aggregate into `connections`; summary
16//! `cards` explain the picture; a deterministic grid `layout` fixes placement
17//! with no pixel math.
18//!
19//! # Independent edge retention (decoupled)
20//!
21//! [`archify_classify_edge`] is this module's **own** edge-retention policy. It
22//! retains the semantically rich cross-service edges (`HttpRequest`, `DbQuery`,
23//! `MessageQueue`, ...) that the raw dot / d2 / mermaid / json exporters
24//! intentionally drop. It does not touch, widen, or share the MCP export
25//! walk's `classify_edge_for_export`, so those existing formats are provably
26//! unchanged by this feature.
27//!
28//! # Determinism
29//!
30//! Same graph + same seeds + same limits produce byte-identical JSON, including
31//! across parallel graph rebuilds. Every ordering is explicit: the seeded walk
32//! ([`super::subgraph`]) is deterministic, component ids are assigned in sorted
33//! cluster-key order with stable collision suffixing, connections sort by
34//! `(from, to)`, grid columns follow stable BFS rank, and elision tie-breaks by
35//! `(degree, id)`.
36//!
37//! # Confidence tiers (fail-safe inference)
38//!
39//! Component-type inference is tiered. High-confidence types are code-evident
40//! (a React component node, an HTTP endpoint, a DB target). Medium/Fallback
41//! types are inferred and degrade to `backend` / `external`, are tagged
42//! `inferred` with their basis in the sublabel, and are listed in an "Inferred
43//! tiers" card. Low-confidence `cloud` / `security` name heuristics are
44//! deliberately deferred in v1 (never emitted); such clusters fall back to
45//! `backend` / `external`.
46
47use 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
60/// Archify JSON schema contract version this exporter targets.
61pub const ARCHIFY_SCHEMA_VERSION: u8 = 1;
62
63/// Default cap on the number of emitted components after grouping.
64pub const DEFAULT_MAX_COMPONENTS: usize = 40;
65
66/// Archify grid layout hard column cap (schema: `cols` in `1..=12`).
67pub const GRID_COL_CAP: usize = 12;
68
69// ============================================================================
70// Errors
71// ============================================================================
72
73/// Fail-closed self-validation errors. The exporter returns one of these
74/// rather than write a malformed diagram to disk.
75#[derive(Debug, thiserror::Error)]
76pub enum ArchifyError {
77    /// The seeded subgraph produced no components (schema requires `minItems:
78    /// 1`), for example an empty or fully language-filtered subgraph.
79    #[error("archify export produced no components (empty or filtered subgraph)")]
80    NoComponents,
81
82    /// No cards were produced (schema/acceptance requires at least one).
83    #[error("archify export produced no summary cards")]
84    NoCards,
85
86    /// A component id does not match Archify's `^[a-zA-Z][a-zA-Z0-9_-]*$`.
87    #[error("component id {0:?} does not match the Archify id pattern")]
88    InvalidComponentId(String),
89
90    /// A connection or boundary referenced an id that is not a defined
91    /// component (a dangling cross-reference).
92    #[error("dangling id reference {0:?} (not a defined component)")]
93    DanglingReference(String),
94
95    /// A boundary wraps zero components (schema requires `minItems: 1`).
96    #[error("boundary {0:?} wraps no components")]
97    EmptyBoundary(String),
98
99    /// A grid row exceeds the 12-column cap even after elision (invariant
100    /// breach; indicates a builder bug).
101    #[error("grid row {row} has width {width} exceeding the {cap}-column cap")]
102    RowTooWide {
103        /// The offending row index.
104        row: usize,
105        /// The row's component count.
106        width: usize,
107        /// The column cap.
108        cap: usize,
109    },
110
111    /// Serialization to JSON failed.
112    #[error("archify JSON serialization failed: {0}")]
113    Serialize(#[from] serde_json::Error),
114}
115
116// ============================================================================
117// Typed model (mirrors Archify architecture.schema.json)
118// ============================================================================
119
120/// Archify component tier type (the seven schema `componentType` values).
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
122#[serde(rename_all = "lowercase")]
123pub enum ComponentType {
124    /// UI / client tier.
125    Frontend,
126    /// Server / service tier.
127    Backend,
128    /// Data store tier.
129    Database,
130    /// Cloud service tier (never inferred in v1).
131    Cloud,
132    /// Security / auth tier (never inferred in v1).
133    Security,
134    /// Message bus / queue / streaming tier.
135    Messagebus,
136    /// External / third-party tier.
137    External,
138}
139
140/// Archify connection visual variant.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
142#[serde(rename_all = "lowercase")]
143pub enum Variant {
144    /// Standard connection.
145    Default,
146    /// Emphasised connection (e.g. an HTTP or queue hop).
147    Emphasis,
148    /// Security-crossing connection (never emitted in v1).
149    Security,
150    /// Dashed / weaker connection (structural or external).
151    Dashed,
152}
153
154/// Archify card accent colour.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
156#[serde(rename_all = "lowercase")]
157pub enum CardDot {
158    /// Cyan accent.
159    Cyan,
160    /// Emerald accent.
161    Emerald,
162    /// Violet accent.
163    Violet,
164    /// Amber accent.
165    Amber,
166    /// Rose accent.
167    Rose,
168    /// Orange accent.
169    Orange,
170    /// Slate accent.
171    Slate,
172}
173
174/// Archify boundary kind.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
176#[serde(rename_all = "kebab-case")]
177pub enum BoundaryKind {
178    /// A region grouping (e.g. a package/directory).
179    Region,
180    /// A security group (never emitted in v1).
181    SecurityGroup,
182}
183
184/// Top-level Archify architecture document.
185#[derive(Debug, Clone, Serialize)]
186pub struct ArchifyDocument {
187    /// Pins the IR contract (`const: 1`).
188    pub schema_version: u8,
189    /// Always `"architecture"` for this exporter.
190    pub diagram_type: &'static str,
191    /// Diagram metadata (title, subtitle).
192    pub meta: ArchifyMeta,
193    /// Grid layout descriptor.
194    pub layout: ArchifyLayout,
195    /// Emitted components (`minItems: 1`).
196    pub components: Vec<ArchifyComponent>,
197    /// Package/region boundaries.
198    #[serde(skip_serializing_if = "Vec::is_empty")]
199    pub boundaries: Vec<ArchifyBoundary>,
200    /// Aggregated connections.
201    #[serde(skip_serializing_if = "Vec::is_empty")]
202    pub connections: Vec<ArchifyConnection>,
203    /// Summary cards (at least one).
204    pub cards: Vec<ArchifyCard>,
205}
206
207/// Diagram metadata block.
208#[derive(Debug, Clone, Serialize)]
209pub struct ArchifyMeta {
210    /// Diagram title (non-empty).
211    pub title: String,
212    /// Optional subtitle.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub subtitle: Option<String>,
215}
216
217/// Grid layout descriptor (`mode: "grid"` only).
218#[derive(Debug, Clone, Serialize)]
219pub struct ArchifyLayout {
220    /// Always `"grid"`.
221    pub mode: &'static str,
222    /// Column count (`1..=12`).
223    pub cols: usize,
224}
225
226/// A single architecture component.
227#[derive(Debug, Clone, Serialize)]
228pub struct ArchifyComponent {
229    /// Stable id matching `^[a-zA-Z][a-zA-Z0-9_-]*$`.
230    pub id: String,
231    /// Component tier type.
232    #[serde(rename = "type")]
233    pub component_type: ComponentType,
234    /// Human-readable label (non-empty).
235    pub label: String,
236    /// Optional sublabel (language, symbol count, inference basis).
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub sublabel: Option<String>,
239    /// Optional tag (`"inferred"` for non-High-confidence tiers).
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub tag: Option<String>,
242    /// Grid row (tier band).
243    pub row: usize,
244    /// Grid column (stable within-row rank).
245    pub col: usize,
246}
247
248/// A package/region boundary wrapping component ids.
249#[derive(Debug, Clone, Serialize)]
250pub struct ArchifyBoundary {
251    /// Boundary kind (`region` in v1).
252    pub kind: BoundaryKind,
253    /// Boundary label (non-empty).
254    pub label: String,
255    /// Wrapped component ids (`minItems: 1`).
256    pub wraps: Vec<String>,
257}
258
259/// An aggregated connection between two components.
260#[derive(Debug, Clone, Serialize)]
261pub struct ArchifyConnection {
262    /// Source component id.
263    pub from: String,
264    /// Target component id.
265    pub to: String,
266    /// Optional edge label.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub label: Option<String>,
269    /// Visual variant.
270    pub variant: Variant,
271}
272
273/// A summary card.
274#[derive(Debug, Clone, Serialize)]
275pub struct ArchifyCard {
276    /// Accent colour.
277    pub dot: CardDot,
278    /// Card title (non-empty).
279    pub title: String,
280    /// Card body lines.
281    pub items: Vec<String>,
282}
283
284// ============================================================================
285// Edge classification (this module's own, decoupled policy)
286// ============================================================================
287
288/// Archify connection category for a retained edge.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
290pub enum ArchifyEdgeClass {
291    /// HTTP request to an endpoint.
292    Http,
293    /// gRPC call.
294    Grpc,
295    /// Database query / table read / table write.
296    Db,
297    /// Message-queue publish/subscribe.
298    Mq,
299    /// WebSocket event.
300    Ws,
301    /// Foreign-function-interface call.
302    Ffi,
303    /// Ordinary call.
304    Calls,
305    /// Import (structural).
306    Import,
307    /// Export (structural).
308    Export,
309}
310
311impl ArchifyEdgeClass {
312    /// Priority for dominant-class selection and stable tie-breaking (lower =
313    /// stronger). Mirrors the traversal edge rank so the picture is coherent.
314    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    /// Whether an edge of this class contributes a "semantic anchor" signal to
329    /// its target (i.e. the target is architecturally interesting on its own).
330    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    /// The connection label + variant this class projects.
338    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/// The Archify edge-retention policy: independent from the raw exporters.
354///
355/// Retains calls/imports/exports and the semantic cross-service edges,
356/// deciding for each whether the BFS should follow it. Structural exports are
357/// retained as connections but not traversed (they would over-expand the
358/// frontier without adding architectural signal).
359#[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// ============================================================================
379// Confidence + inference
380// ============================================================================
381
382/// Inference confidence for a component's type.
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384enum Confidence {
385    /// Code-evident (a real component/endpoint/DB target). Not tagged.
386    High,
387    /// Inferred from a weaker signal (external target). Tagged `inferred`.
388    Medium,
389    /// Fallback default with no signal. Tagged `inferred`.
390    Fallback,
391}
392
393impl Confidence {
394    const fn is_high(self) -> bool {
395        matches!(self, Self::High)
396    }
397}
398
399// ============================================================================
400// Exporter configuration
401// ============================================================================
402
403/// Archify exporter configuration.
404#[derive(Debug, Clone)]
405pub struct ArchifyConfig {
406    /// Seed description surfaced in the Overview card (e.g. a symbol name).
407    pub seed_label: String,
408    /// Diagram title. When empty a default is derived from the seed label.
409    pub title: String,
410    /// BFS depth used for the subgraph (surfaced in cards; the walk itself is
411    /// configured through [`SeededSubgraphConfig`]).
412    pub max_depth: usize,
413    /// Readability cap on emitted components after grouping.
414    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
428// ============================================================================
429// Build entrypoint
430// ============================================================================
431
432/// Build an Archify architecture document from concrete seeds.
433///
434/// Runs the shared seeded-subgraph walk with the Archify edge classifier, then
435/// groups, infers, lays out, and self-validates. Fail-closed: returns an
436/// [`ArchifyError`] rather than emit a malformed diagram.
437///
438/// # Errors
439///
440/// Returns [`ArchifyError`] if the subgraph is empty/fully filtered, or if the
441/// self-validation invariants are violated (which would indicate a builder
442/// bug).
443pub 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/// An Archify document plus the seeded-walk signals ([`ArchifyBuildMeta`])
453/// callers need to report accurate `truncated` / `total` fields, instead of
454/// hardcoding them (the walk itself is the only place that knows whether
455/// `max_results` was hit).
456#[derive(Debug, Clone)]
457pub struct ArchifyBuildMeta {
458    /// The built Archify architecture document.
459    pub document: ArchifyDocument,
460    /// `true` if the seeded subgraph walk stopped early because
461    /// `subgraph_config.max_results` was reached (see
462    /// [`super::subgraph::SeededSubgraph::truncated`]).
463    pub truncated: bool,
464    /// Number of retained edges the seeded walk produced (before elision /
465    /// grid placement pruned components, but after the Archify edge
466    /// classifier's own retention policy).
467    pub total_edges: usize,
468}
469
470/// Build an Archify document, also returning the seeded-walk's
471/// truncation/total signals.
472///
473/// # Errors
474///
475/// Returns [`ArchifyError`] if the subgraph is empty/fully filtered, or if the
476/// self-validation invariants are violated (which would indicate a builder
477/// bug).
478pub 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
500/// Build an Archify document and serialize it to pretty JSON bytes-string.
501///
502/// # Errors
503///
504/// Propagates [`ArchifyError`] from the build/self-validation, or a
505/// serialization error.
506pub 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
516// ============================================================================
517// Builder
518// ============================================================================
519
520/// A component under construction (pre-serialization).
521struct DraftComponent {
522    /// Stable cluster key (used for id assignment ordering).
523    cluster_key: String,
524    /// Assigned Archify id (filled during id assignment).
525    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    /// Earliest BFS discovery rank across the cluster's nodes (for col order).
534    bfs_rank: usize,
535    /// Assigned grid `(row, col)` (filled during grid placement).
536    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        // 1. Per-node signals from retained edges.
552        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        // 2. Type inference per cluster.
560        self.infer_types(&node_to_cluster, &mut drafts);
561
562        // 3. Assign stable ids (sorted cluster-key order, collision suffixing).
563        assign_ids(&mut drafts);
564
565        // 4. Aggregate connections between clusters.
566        let mut connections = self.aggregate_connections(&node_to_cluster, &drafts);
567
568        // 5. Elide down to max_components + per-row width cap.
569        let low_degree_elided =
570            elide_components(&mut drafts, &connections, self.config.max_components);
571
572        // 6. Grid placement (rows by tier, cols by BFS rank). `place_grid` can
573        // drop further components when a row exceeds `GRID_COL_CAP`; that
574        // count must be folded into the caveat total below, otherwise the
575        // caveat card under-reports how many components actually vanished.
576        let (cols, grid_cap_elided) = place_grid(&mut drafts)?;
577        let elided = low_degree_elided + grid_cap_elided;
578
579        // 7. Drop connections that reference elided components.
580        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        // 8. Boundaries per package.
585        let boundaries = build_boundaries(&drafts);
586
587        // 9. Cards (Overview always present).
588        let languages = self.languages_present();
589        let cards = build_cards(&drafts, &connections, self.config, &languages, elided);
590
591        // 10. Assemble + self-validate (fail-closed).
592        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    /// Set of node ids that are the target of at least one semantic edge.
623    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    /// Assign every visited node to a cluster and produce draft components.
633    ///
634    /// Standalone anchors (semantic node kinds or semantic-edge targets) get
635    /// their own cluster; everything else groups by file path.
636    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    /// Infer a component type + confidence for each cluster.
687    fn infer_types(&self, node_to_cluster: &HashMap<NodeId, usize>, drafts: &mut [DraftComponent]) {
688        // Per-cluster signal accumulation.
689        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        // Node-kind signals.
698        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        // Edge-target signals.
709        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            // Precedence: highest-confidence, most-specific signal wins.
724            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    /// Aggregate retained cross-cluster edges into deduped connections.
772    fn aggregate_connections(
773        &self,
774        node_to_cluster: &HashMap<NodeId, usize>,
775        drafts: &[DraftComponent],
776    ) -> Vec<ArchifyConnection> {
777        // (from_idx, to_idx) -> class -> count.
778        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; // intra-cluster edges collapse into the component
788            }
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        // Deterministic order by (from, to).
810        connections.sort_by(|a, b| a.from.cmp(&b.from).then_with(|| a.to.cmp(&b.to)));
811        connections
812    }
813
814    /// Sorted, de-duplicated list of languages present in the subgraph.
815    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    // ---- node field resolution helpers ----
826
827    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            // Surface the inference basis on the sublabel for non-High tiers.
856            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
871// ============================================================================
872// Free helpers
873// ============================================================================
874
875const 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
886/// Pick the dominant class: highest count, ties broken by class priority.
887fn 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
897/// Immediate parent directory of a workspace-relative file path (the package),
898/// or `(root)` for a top-level file.
899fn 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
906/// File basename for a component label.
907fn file_basename(file: &str) -> String {
908    file.rsplit_once('/')
909        .map_or_else(|| file.to_string(), |(_, base)| base.to_string())
910}
911
912/// Sanitize any string into Archify's `^[a-zA-Z][a-zA-Z0-9_-]*$` id pattern.
913///
914/// Deterministic: disallowed characters become `-`, consecutive `-` collapse,
915/// and an id not starting with a letter is prefixed with `c`. Never empty.
916fn 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
941/// Assign stable ids to drafts in sorted cluster-key order, suffixing on
942/// collision so cross-references stay valid.
943fn 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
964/// Elide low-degree components to satisfy `max_components`. Returns the number
965/// elided. Tie-break: lowest degree, then id ascending (stable).
966fn 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    // Rank components by keep-priority: higher degree kept; ties by id asc.
977    let mut order: Vec<usize> = (0..drafts.len()).collect();
978    order.sort_by(|&a, &b| {
979        degree[b]
980            .cmp(&degree[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
995/// Degree (connection count touching each component) by draft index.
996fn 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
1014/// Assign grid rows (by tier) and columns (by BFS rank within row), enforcing
1015/// the 12-column cap by further elision inside any over-wide row.
1016///
1017/// Returns `(cols, dropped)`: the chosen `cols` value and the total number of
1018/// components this call (including any recursive re-placement passes) had to
1019/// drop to satisfy the per-row cap. Callers must fold `dropped` into the
1020/// overall elided-component count fed to the caveat card, since these drops
1021/// happen after (and independently of) `elide_components`.
1022fn place_grid(drafts: &mut Vec<DraftComponent>) -> Result<(usize, usize), ArchifyError> {
1023    // Compute component set that survives the per-row width cap. Group indices
1024    // by row.
1025    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    // Within each row, order by BFS rank (then id) and cap width; over-cap
1033    // components are elided.
1034    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        // Re-place after dropping (indices shifted); accumulate the drop
1062        // count across recursive passes so the caller sees the true total.
1063        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    // Invariant: no row wider than the cap.
1075    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
1092/// Fixed tier -> grid row mapping (bands top to bottom).
1093const 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
1104/// Build one region boundary per package (sorted), wrapping its component ids.
1105fn 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
1126/// Build the summary cards. The Overview card is always present.
1127fn 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    // Inferred tiers (only if any non-High component exists).
1154    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    // Caveats (always present).
1175    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
1211/// Regex-free check for the Archify id pattern `^[a-zA-Z][a-zA-Z0-9_-]*$`.
1212fn 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
1221/// Fail-closed self-validation of the assembled document.
1222fn 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    // Grid row-width invariant.
1257    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    /// Minimal node builder for fixtures.
1300    #[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    /// A small polyglot architecture: frontend component -> HTTP endpoint ->
1342    /// handler -> query fn -> database table.
1343    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    // ---- sanitize_id ----
1482
1483    #[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        // Sorted cluster-key order -> a, b, c get x-rs, x-rs-1, x-rs-2.
1522        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    // ---- inference + structure ----
1542
1543    #[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        // Frontend component is code-evident, not tagged inferred.
1551        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        // Endpoint node -> backend High.
1560        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        // DB target -> database High.
1568        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        // At least one card (Overview), and the standing sqry/Archify note.
1576        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        // HTTP connection is emphasised.
1586        assert!(
1587            doc.connections
1588                .iter()
1589                .any(|c| matches!(c.variant, Variant::Emphasis)
1590                    && c.label.as_deref() == Some("HTTP"))
1591        );
1592
1593        // Boundaries wrap real component ids.
1594        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        // Names that a heuristic might tag cloud/security must stay fallback.
1607        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    // ---- determinism ----
1661
1662    #[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        // Rebuild the fixture graph independently several times (arena/edge
1674        // allocation order can differ) and assert identical bytes. The build
1675        // here is single-threaded, but the exporter's determinism must not
1676        // depend on iteration order, which is what this locks.
1677        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    // ---- limits / elision ----
1690
1691    #[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        // Row-width invariant.
1700        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        // Caveat card reflects elision.
1706        assert!(
1707            doc.cards
1708                .iter()
1709                .flat_map(|c| &c.items)
1710                .any(|i| i.contains("Elided"))
1711        );
1712    }
1713
1714    /// Regression test for the caveat under-reporting elided components: a
1715    /// grid-cap drop inside `place_grid` (triggered by a row exceeding
1716    /// `GRID_COL_CAP`) must be folded into the caveat's elided total even
1717    /// when `elide_components` itself drops nothing. Before the fix,
1718    /// `elided` was computed before `place_grid` ran, so this fixture (well
1719    /// under `max_components` but over the per-row width cap) produced a
1720    /// caveat count of 0 despite silently dropping components, or no caveat
1721    /// line at all. This test would FAIL under that pre-fix ordering.
1722    #[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        // 15 distinct-file helper functions called directly by `handler`.
1790        // Each lands in its own file-scoped cluster with no non-default
1791        // type signal, so all classify as `Backend` (fallback) alongside
1792        // `endpoint` and `handler` -- 17 components sharing row 2, well
1793        // past `GRID_COL_CAP` (12), while the total component count (18)
1794        // stays far under `DEFAULT_MAX_COMPONENTS` (40) so
1795        // `elide_components` never fires.
1796        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        // Row-width invariant still holds: no row wider than the cap.
1840        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        // endpoint + handler + 15 helpers = 17 candidates for row 2; the cap
1847        // forces exactly 5 to be dropped at the grid stage, on top of the 0
1848        // that `elide_components` drops (18 total components <= 40).
1849        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    // ---- fail-closed ----
1872
1873    #[test]
1874    fn empty_subgraph_errors_closed() {
1875        let fx = build_fixture();
1876        let (sub, arch) = full_config();
1877        // No seeds -> empty subgraph -> NoComponents (never writes).
1878        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            // Seed node is JavaScript; filter to a language not present.
1890            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    // ---- schema conformance (hermetic, vendored schema + jsonschema crate) ----
1899
1900    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        // Sample 1: full polyglot fixture.
1941        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        // Sample 2: seed with no neighbours (single component).
1956        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        // Sample 3: limit-forced elision.
1993        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}