Skip to main content

lean_ctx_protocol/hive/
codegraph.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The published code graph (C0 1.9, section 8; plan N1).
4//!
5//! By default Hive knows code only through fact anchors (`code_graph`
6//! `anchors`, section 3 `GraphV1` derives code nodes from them). The vault
7//! owner may publish the full graph (`VaultV1.code_graph` `full`, set on
8//! the web). Then an engine sends it on an explicit publish, or after a pull
9//! that shows the setting changed:
10//! `PUT /v1/vaults/{vault_id}/code-graph` [`CodeGraphPutV1`] →
11//! [`CodeGraphAckV1`] (right `knowledge.draft`). With the setting at
12//! `anchors` that is `409 invalid_state` reason `code_graph_disabled`.
13//! Every upload replaces the vault's graph; Hive records it in the audit
14//! log with the publishing instance, and shows who published the current
15//! graph.
16//!
17//! The graph names files and symbols and how they relate, nothing else: no
18//! source text, no bodies, no digests (a hash of short code can be guessed).
19//! Paths follow the anchor rules and pass the same admission as anchors on
20//! the engine (restricted or secret files are dropped from the graph).
21
22use serde::{Deserialize, Serialize};
23
24use super::anchor::validate_anchor_path;
25use super::{require_schema, validate_text};
26use crate::ValidationError;
27
28pub const CODE_GRAPH_SCHEMA: &str = "leanctx.hive.code-graph/v1";
29pub const CODE_GRAPH_PUT_SCHEMA: &str = "leanctx.hive.code-graph-put/v1";
30pub const CODE_GRAPH_ACK_SCHEMA: &str = "leanctx.hive.code-graph-ack/v1";
31pub const MAX_CODE_GRAPH_NODES: usize = super::graph::MAX_GRAPH_NODES;
32pub const MAX_CODE_GRAPH_EDGES: usize = super::graph::MAX_GRAPH_EDGES;
33/// Characters of a code node id, as for `GraphV1` node ids.
34pub const MAX_CODE_NODE_ID_CHARS: usize = 700;
35
36/// `VaultV1.code_graph`; absent means `anchors`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum CodeGraphMode {
40    Anchors,
41    Full,
42    #[serde(other)]
43    Unknown,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum CodeNodeKind {
49    File,
50    Symbol,
51    #[serde(other)]
52    Unknown,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum CodeEdgeKind {
58    /// file → symbol, symbol → nested symbol.
59    Contains,
60    /// file → file.
61    Imports,
62    /// symbol → symbol.
63    Calls,
64    #[serde(other)]
65    Unknown,
66}
67
68/// `code:<path>` (a file) or `code:<path>#<symbol>`, as in `GraphV1`.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct CodeNodeV1 {
72    pub id: String,
73    pub kind: CodeNodeKind,
74    /// Tree-sitter language id, a hint.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub lang: Option<String>,
77    /// `fn`, `type`, `trait`, `const`, … for symbols, a hint.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub symbol_kind: Option<String>,
80}
81
82impl CodeNodeV1 {
83    fn validate(&self) -> Result<(), ValidationError> {
84        if self.id.chars().count() > MAX_CODE_NODE_ID_CHARS {
85            return Err(ValidationError::new(format!(
86                "a code node id has at most {MAX_CODE_NODE_ID_CHARS} characters"
87            )));
88        }
89        let rest = self
90            .id
91            .strip_prefix("code:")
92            .ok_or_else(|| ValidationError::new("code node ids start with code:"))?;
93        let (path, symbol) = match rest.split_once('#') {
94            Some((path, symbol)) => (path, Some(symbol)),
95            None => (rest, None),
96        };
97        validate_anchor_path(path)?;
98        match (self.kind, symbol) {
99            (CodeNodeKind::File, None) => {}
100            (CodeNodeKind::Symbol, Some(symbol)) => {
101                validate_text(
102                    symbol,
103                    super::anchor::MAX_ANCHOR_SYMBOL_CHARS,
104                    "symbol",
105                    false,
106                )?;
107            }
108            _ => {
109                return Err(ValidationError::new(
110                    "a file node has no #symbol and a symbol node has one",
111                ));
112            }
113        }
114        for (hint, field) in [(&self.lang, "lang"), (&self.symbol_kind, "symbol_kind")] {
115            if let Some(hint) = hint
116                && (hint.is_empty()
117                    || hint.len() > 32
118                    || !hint
119                        .bytes()
120                        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'))
121            {
122                return Err(ValidationError::new(format!(
123                    "{field} is 1-32 characters of [a-z0-9_]"
124                )));
125            }
126        }
127        Ok(())
128    }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct CodeEdgeV1 {
134    pub from: String,
135    pub to: String,
136    #[serde(rename = "type")]
137    pub kind: CodeEdgeKind,
138}
139
140/// The engine's code graph of one repo.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct CodeGraphV1 {
144    pub schema: String,
145    /// The git commit the graph was built from (40 or 64 lowercase hex).
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub revision_hint: Option<String>,
148    /// Sorted by `id`, without repeats.
149    pub nodes: Vec<CodeNodeV1>,
150    /// Sorted by `(from, to, type)`, without repeats; both ends are nodes.
151    pub edges: Vec<CodeEdgeV1>,
152}
153
154impl CodeGraphV1 {
155    pub fn validate(&self) -> Result<(), ValidationError> {
156        require_schema(&self.schema, CODE_GRAPH_SCHEMA)?;
157        if let Some(hint) = &self.revision_hint
158            && !(matches!(hint.len(), 40 | 64)
159                && hint
160                    .bytes()
161                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
162        {
163            return Err(ValidationError::new("revision_hint is a git commit id"));
164        }
165        if self.nodes.len() > MAX_CODE_GRAPH_NODES || self.edges.len() > MAX_CODE_GRAPH_EDGES {
166            return Err(ValidationError::new(format!(
167                "a code graph has at most {MAX_CODE_GRAPH_NODES} nodes and {MAX_CODE_GRAPH_EDGES} edges"
168            )));
169        }
170        for (i, node) in self.nodes.iter().enumerate() {
171            node.validate()?;
172            if i > 0 && self.nodes[i - 1].id >= node.id {
173                return Err(ValidationError::new(
174                    "nodes must be sorted by id without repeats",
175                ));
176            }
177        }
178        let known = |id: &str| {
179            self.nodes
180                .binary_search_by(|node| node.id.as_str().cmp(id))
181                .is_ok()
182        };
183        for (i, edge) in self.edges.iter().enumerate() {
184            if edge.kind == CodeEdgeKind::Unknown {
185                return Err(ValidationError::new("unknown edge type"));
186            }
187            if !known(&edge.from) || !known(&edge.to) {
188                return Err(ValidationError::new("an edge names a missing node"));
189            }
190            if i > 0 {
191                let prev = &self.edges[i - 1];
192                if (&prev.from, &prev.to, prev.kind) >= (&edge.from, &edge.to, edge.kind) {
193                    return Err(ValidationError::new("edges must be sorted without repeats"));
194                }
195            }
196        }
197        Ok(())
198    }
199}
200
201/// `PUT …/code-graph`: the graph, in the clear.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(deny_unknown_fields)]
204pub struct CodeGraphPutV1 {
205    pub schema: String,
206    pub graph: CodeGraphV1,
207}
208
209impl CodeGraphPutV1 {
210    pub fn validate(&self) -> Result<(), ValidationError> {
211        require_schema(&self.schema, CODE_GRAPH_PUT_SCHEMA)?;
212        self.graph.validate()
213    }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct CodeGraphAckV1 {
218    pub schema: String,
219    /// Stored counts.
220    pub nodes: u64,
221    pub edges: u64,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub revision_hint: Option<String>,
224}