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//!
22//! A sealed vault sends the same [`CodeGraphV1`] only as `sealed`
23//! (content `leanctx.hive.code-graph/v1`, AAD
24//! `vault_id|0|epoch|content_schema`). Hive stores it opaquely; its
25//! `GraphV1` stays `409 sealed` and engines merge the graph locally.
26
27use serde::{Deserialize, Serialize};
28
29use super::anchor::validate_anchor_path;
30use super::sealed::SealedBlobV1;
31use super::{require_schema, validate_text};
32use crate::ValidationError;
33
34pub const CODE_GRAPH_SCHEMA: &str = "leanctx.hive.code-graph/v1";
35pub const CODE_GRAPH_PUT_SCHEMA: &str = "leanctx.hive.code-graph-put/v1";
36pub const CODE_GRAPH_ACK_SCHEMA: &str = "leanctx.hive.code-graph-ack/v1";
37pub const MAX_CODE_GRAPH_NODES: usize = super::graph::MAX_GRAPH_NODES;
38pub const MAX_CODE_GRAPH_EDGES: usize = super::graph::MAX_GRAPH_EDGES;
39/// Characters of a code node id, as for `GraphV1` node ids.
40pub const MAX_CODE_NODE_ID_CHARS: usize = 700;
41
42/// `VaultV1.code_graph`; absent means `anchors`.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum CodeGraphMode {
46    Anchors,
47    Full,
48    #[serde(other)]
49    Unknown,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum CodeNodeKind {
55    File,
56    Symbol,
57    #[serde(other)]
58    Unknown,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum CodeEdgeKind {
64    /// file → symbol, symbol → nested symbol.
65    Contains,
66    /// file → file.
67    Imports,
68    /// symbol → symbol.
69    Calls,
70    #[serde(other)]
71    Unknown,
72}
73
74/// `code:<path>` (a file) or `code:<path>#<symbol>`, as in `GraphV1`.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct CodeNodeV1 {
78    pub id: String,
79    pub kind: CodeNodeKind,
80    /// Tree-sitter language id, a hint.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub lang: Option<String>,
83    /// `fn`, `type`, `trait`, `const`, … for symbols, a hint.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub symbol_kind: Option<String>,
86}
87
88impl CodeNodeV1 {
89    fn validate(&self) -> Result<(), ValidationError> {
90        if self.id.chars().count() > MAX_CODE_NODE_ID_CHARS {
91            return Err(ValidationError::new(format!(
92                "a code node id has at most {MAX_CODE_NODE_ID_CHARS} characters"
93            )));
94        }
95        let rest = self
96            .id
97            .strip_prefix("code:")
98            .ok_or_else(|| ValidationError::new("code node ids start with code:"))?;
99        let (path, symbol) = match rest.split_once('#') {
100            Some((path, symbol)) => (path, Some(symbol)),
101            None => (rest, None),
102        };
103        validate_anchor_path(path)?;
104        match (self.kind, symbol) {
105            (CodeNodeKind::File, None) => {}
106            (CodeNodeKind::Symbol, Some(symbol)) => {
107                validate_text(
108                    symbol,
109                    super::anchor::MAX_ANCHOR_SYMBOL_CHARS,
110                    "symbol",
111                    false,
112                )?;
113            }
114            _ => {
115                return Err(ValidationError::new(
116                    "a file node has no #symbol and a symbol node has one",
117                ));
118            }
119        }
120        for (hint, field) in [(&self.lang, "lang"), (&self.symbol_kind, "symbol_kind")] {
121            if let Some(hint) = hint
122                && (hint.is_empty()
123                    || hint.len() > 32
124                    || !hint
125                        .bytes()
126                        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'))
127            {
128                return Err(ValidationError::new(format!(
129                    "{field} is 1-32 characters of [a-z0-9_]"
130                )));
131            }
132        }
133        Ok(())
134    }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct CodeEdgeV1 {
140    pub from: String,
141    pub to: String,
142    #[serde(rename = "type")]
143    pub kind: CodeEdgeKind,
144}
145
146/// The engine's code graph of one repo (also the sealed content).
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct CodeGraphV1 {
150    pub schema: String,
151    /// The git commit the graph was built from (40 or 64 lowercase hex).
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub revision_hint: Option<String>,
154    /// Sorted by `id`, without repeats.
155    pub nodes: Vec<CodeNodeV1>,
156    /// Sorted by `(from, to, type)`, without repeats; both ends are nodes.
157    pub edges: Vec<CodeEdgeV1>,
158}
159
160impl CodeGraphV1 {
161    pub fn validate(&self) -> Result<(), ValidationError> {
162        require_schema(&self.schema, CODE_GRAPH_SCHEMA)?;
163        if let Some(hint) = &self.revision_hint
164            && !(matches!(hint.len(), 40 | 64)
165                && hint
166                    .bytes()
167                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
168        {
169            return Err(ValidationError::new("revision_hint is a git commit id"));
170        }
171        if self.nodes.len() > MAX_CODE_GRAPH_NODES || self.edges.len() > MAX_CODE_GRAPH_EDGES {
172            return Err(ValidationError::new(format!(
173                "a code graph has at most {MAX_CODE_GRAPH_NODES} nodes and {MAX_CODE_GRAPH_EDGES} edges"
174            )));
175        }
176        for (i, node) in self.nodes.iter().enumerate() {
177            node.validate()?;
178            if i > 0 && self.nodes[i - 1].id >= node.id {
179                return Err(ValidationError::new(
180                    "nodes must be sorted by id without repeats",
181                ));
182            }
183        }
184        let known = |id: &str| {
185            self.nodes
186                .binary_search_by(|node| node.id.as_str().cmp(id))
187                .is_ok()
188        };
189        for (i, edge) in self.edges.iter().enumerate() {
190            if edge.kind == CodeEdgeKind::Unknown {
191                return Err(ValidationError::new("unknown edge type"));
192            }
193            if !known(&edge.from) || !known(&edge.to) {
194                return Err(ValidationError::new("an edge names a missing node"));
195            }
196            if i > 0 {
197                let prev = &self.edges[i - 1];
198                if (&prev.from, &prev.to, prev.kind) >= (&edge.from, &edge.to, edge.kind) {
199                    return Err(ValidationError::new("edges must be sorted without repeats"));
200                }
201            }
202        }
203        Ok(())
204    }
205}
206
207/// `PUT …/code-graph`: exactly one of `graph` (clear) and `sealed`.
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct CodeGraphPutV1 {
211    pub schema: String,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub graph: Option<CodeGraphV1>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub sealed: Option<SealedBlobV1>,
216}
217
218impl CodeGraphPutV1 {
219    pub fn validate(&self) -> Result<(), ValidationError> {
220        require_schema(&self.schema, CODE_GRAPH_PUT_SCHEMA)?;
221        match (&self.graph, &self.sealed) {
222            (Some(graph), None) => graph.validate(),
223            (None, Some(sealed)) => sealed.validate(),
224            _ => Err(ValidationError::new(
225                "a code graph upload carries exactly one of graph and sealed",
226            )),
227        }
228    }
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct CodeGraphAckV1 {
233    pub schema: String,
234    /// Stored counts; 0 for a sealed upload (Hive cannot count it).
235    pub nodes: u64,
236    pub edges: u64,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub revision_hint: Option<String>,
239}