Skip to main content

rto_graph/
codegraph.rs

1//! codegraph **validation oracle** (not an importer).
2//!
3//! [code-graph-mcp](https://github.com/sdsrss/code-graph-mcp) builds an
4//! independent tree-sitter AST graph in a portable `SQLite` snapshot. Per ADR-0001
5//! its structural edges are **not** imported — Roteiro re-derives those — but the
6//! snapshot is a useful *oracle*: [`compare`] checks Roteiro's derived Rust
7//! symbols and `calls` edges against codegraph's and reports agreement and
8//! divergence, so extraction gaps on either side surface. Read-only; nothing is
9//! written to the store.
10//!
11//! Snapshot schema (v10): `files(id, path, …)`, `nodes(id, file_id, type,
12//! qualified_name, …)`, `edges(source_id, target_id, relation, …)`,
13//! `meta(key, value)`. A codegraph method's `qualified_name` uses `.` scoping
14//! (`Store.open`); Roteiro uses `::`, so keys map as
15//! `sym:rust:<path>#<qualified_name with '.'→'::'>`.
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::path::Path;
19
20use rusqlite::{Connection, OpenFlags, OptionalExtension};
21
22use crate::store::{Store, StoreError};
23use crate::{EdgeKind, NodeKind};
24
25/// Stable schema tag for the oracle report.
26pub const ORACLE_SCHEMA: &str = "roteiro.oracle/v1";
27
28/// Errors raised while comparing against a codegraph snapshot.
29#[derive(Debug, thiserror::Error)]
30pub enum OracleError {
31    /// Reading the codegraph snapshot failed.
32    #[error("codegraph sqlite error: {0}")]
33    Sqlite(#[from] rusqlite::Error),
34    /// Reading the Roteiro store failed.
35    #[error(transparent)]
36    Store(#[from] StoreError),
37    /// The file is not a recognisable codegraph snapshot.
38    #[error("not a codegraph snapshot: {0}")]
39    NotCodegraph(String),
40}
41
42/// The result of comparing Roteiro's derived graph against a codegraph snapshot.
43/// Counts cover **Rust** `function`/`struct`/`enum`/`trait` symbols (the overlap
44/// where both tools operate) and function-to-function `calls`.
45#[derive(Debug, Clone, serde::Serialize)]
46pub struct OracleReport {
47    /// Stable schema tag ([`ORACLE_SCHEMA`]).
48    pub schema: &'static str,
49    /// The commit codegraph indexed, from the snapshot `meta` (for context —
50    /// a mismatch with the current `HEAD` explains divergence).
51    pub source_commit: Option<String>,
52    /// Comparable Rust symbols codegraph found.
53    pub symbols_codegraph: usize,
54    /// Comparable Rust symbols in Roteiro's derived graph.
55    pub symbols_roteiro: usize,
56    /// Symbols present in both with the *same* key (exact agreement).
57    pub symbols_matched: usize,
58    /// Symbols both tools found in the same file with the same leaf name but a
59    /// **different scope** (e.g. codegraph `#foo` vs Roteiro `#tests::foo`) — the
60    /// same symbol, keyed differently, not a real divergence.
61    pub symbols_scope_diff: usize,
62    /// Symbols codegraph found that Roteiro genuinely lacks (no same-file,
63    /// same-leaf match) — a real extraction-coverage gap.
64    pub codegraph_only: usize,
65    /// Symbols Roteiro found that codegraph genuinely lacks.
66    pub roteiro_only: usize,
67    /// A capped, ordered sample of the genuine codegraph-only keys.
68    pub codegraph_only_sample: Vec<String>,
69    /// A capped, ordered sample of the genuine roteiro-only keys.
70    pub roteiro_only_sample: Vec<String>,
71    /// Constants codegraph extracted — a known Roteiro gap (it does not yet
72    /// extract `const`/`static`), reported so the symbol counts stay honest.
73    pub constants_codegraph: usize,
74    /// Internal function→function `calls` edges codegraph found (both ends Rust).
75    pub calls_codegraph: usize,
76    /// Of those, how many Roteiro also has (agreement).
77    pub calls_agree: usize,
78    /// codegraph calls Roteiro lacks — expected, since Roteiro only links
79    /// unambiguously-resolved calls while codegraph resolves by name too.
80    pub calls_codegraph_only: usize,
81}
82
83/// Maximum number of divergent keys listed in a sample.
84const SAMPLE_CAP: usize = 25;
85
86/// Compare Roteiro's derived graph (`store`) against a codegraph snapshot at
87/// `db_path`, returning an [`OracleReport`]. Read-only on both sides.
88///
89/// # Errors
90/// Returns [`OracleError::NotCodegraph`] if the file lacks codegraph's tables,
91/// [`OracleError::Sqlite`] on snapshot read failure, or [`OracleError::Store`]
92/// on store read failure.
93pub fn compare(db_path: &Path, store: &Store) -> Result<OracleReport, OracleError> {
94    let conn = Connection::open_with_flags(
95        db_path,
96        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
97    )?;
98    ensure_codegraph(&conn, db_path)?;
99
100    // `meta` is guaranteed to exist by `ensure_codegraph`, so a missing row means
101    // no commit was recorded (→ None); a real read error propagates.
102    let source_commit = conn
103        .query_row(
104            "SELECT value FROM meta WHERE key = 'snapshot_source_commit'",
105            [],
106            |r| r.get::<_, String>(0),
107        )
108        .optional()?;
109
110    // codegraph side: comparable Rust symbols and internal calls.
111    let cg_symbols = codegraph_symbols(&conn)?;
112    let constants_codegraph = codegraph_constant_count(&conn)?;
113    let cg_calls = codegraph_calls(&conn, &cg_symbols)?;
114
115    // Roteiro side: derive from a single graph dump.
116    let facts = store.export_factset()?;
117    let ro_symbols: BTreeSet<String> = facts
118        .nodes
119        .iter()
120        .filter(|n| is_comparable_kind(&n.kind))
121        .map(|n| n.key.clone())
122        .collect();
123    let ro_calls: BTreeSet<(String, String)> = facts
124        .edges
125        .iter()
126        .filter(|e| e.kind == EdgeKind::Calls)
127        .map(|e| (e.src.clone(), e.dst.clone()))
128        .collect();
129
130    let matched = cg_symbols.intersection(&ro_symbols).count();
131
132    // Of the non-exact remainder, a symbol both tools found in the same file
133    // under the same leaf name (but a different scope, e.g. `#foo` vs
134    // `#tests::foo`) is a scoping difference, not a real gap. Count per
135    // `(path, leaf)` so multiplicity is preserved: when a file has more symbols
136    // with a leaf on one side, the surplus is a genuine gap, not a scope diff.
137    let cg_rest: Vec<&String> = cg_symbols.difference(&ro_symbols).collect();
138    let ro_rest: Vec<&String> = ro_symbols.difference(&cg_symbols).collect();
139    let mut leaf_counts: BTreeMap<(String, String), [usize; 2]> = BTreeMap::new();
140    for k in &cg_rest {
141        leaf_counts.entry(path_leaf(k)).or_default()[0] += 1;
142    }
143    for k in &ro_rest {
144        leaf_counts.entry(path_leaf(k)).or_default()[1] += 1;
145    }
146    let scope_diff: usize = leaf_counts.values().map(|[c, r]| (*c).min(*r)).sum();
147
148    // Genuine gaps are the per-group surplus on each side; the sample keys are
149    // the actual surplus symbols (deterministic — `*_rest` are sorted).
150    let cg_only = surplus_keys(&cg_rest, &leaf_counts, 0);
151    let ro_only = surplus_keys(&ro_rest, &leaf_counts, 1);
152
153    let calls_agree = cg_calls.iter().filter(|c| ro_calls.contains(*c)).count();
154
155    Ok(OracleReport {
156        schema: ORACLE_SCHEMA,
157        source_commit,
158        symbols_codegraph: cg_symbols.len(),
159        symbols_roteiro: ro_symbols.len(),
160        symbols_matched: matched,
161        symbols_scope_diff: scope_diff,
162        codegraph_only: cg_only.len(),
163        roteiro_only: ro_only.len(),
164        codegraph_only_sample: cg_only.into_iter().take(SAMPLE_CAP).collect(),
165        roteiro_only_sample: ro_only.into_iter().take(SAMPLE_CAP).collect(),
166        constants_codegraph,
167        calls_codegraph: cg_calls.len(),
168        calls_agree,
169        calls_codegraph_only: cg_calls.len() - calls_agree,
170    })
171}
172
173/// Whether a Roteiro node kind is one codegraph also extracts (the comparable
174/// overlap): functions/methods, structs, enums, traits.
175fn is_comparable_kind(kind: &NodeKind) -> bool {
176    matches!(
177        kind,
178        NodeKind::Fn | NodeKind::Struct | NodeKind::Enum | NodeKind::Trait
179    )
180}
181
182/// Confirm the snapshot has codegraph's core tables (including `meta`, which
183/// [`compare`] reads). A real read error propagates; only a genuinely absent
184/// table yields [`OracleError::NotCodegraph`].
185fn ensure_codegraph(conn: &Connection, path: &Path) -> Result<(), OracleError> {
186    for table in ["files", "nodes", "edges", "meta"] {
187        let present: Option<i64> = conn
188            .query_row(
189                "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1",
190                [table],
191                |r| r.get(0),
192            )
193            .optional()?;
194        if present.is_none() {
195            return Err(OracleError::NotCodegraph(format!(
196                "{} has no `{table}` table",
197                path.display()
198            )));
199        }
200    }
201    Ok(())
202}
203
204/// The Roteiro key for a codegraph symbol at `path` with codegraph `qualified`
205/// (`.`-scoped): `sym:rust:<path>#<qualified with '.'→'::'>`.
206fn symbol_key(path: &str, qualified: &str) -> String {
207    format!("sym:rust:{path}#{}", qualified.replace('.', "::"))
208}
209
210/// The genuine-gap keys on one `side` (0 = codegraph, 1 = Roteiro): for each
211/// `(path, leaf)` group, the surplus over the other side (`max(0, mine - other)`)
212/// worth of keys, drawn in order from the (sorted) `rest`. Symbols matched by a
213/// same-leaf counterpart on the other side are scope diffs, not gaps, and are
214/// skipped.
215fn surplus_keys(
216    rest: &[&String],
217    counts: &BTreeMap<(String, String), [usize; 2]>,
218    side: usize,
219) -> Vec<String> {
220    let other = 1 - side;
221    let mut budget: BTreeMap<(String, String), usize> = counts
222        .iter()
223        .map(|(k, c)| (k.clone(), c[side].saturating_sub(c[other])))
224        .collect();
225    let mut out = Vec::new();
226    for key in rest {
227        if let Some(remaining) = budget.get_mut(&path_leaf(key))
228            && *remaining > 0
229        {
230            *remaining -= 1;
231            out.push((*key).clone());
232        }
233    }
234    out
235}
236
237/// The `(path, leaf-name)` of a `sym:rust:<path>#<qual>` key, where the leaf is
238/// the final `::`-segment of the qualified name. Used to detect the same symbol
239/// keyed under a different scope (`#foo` vs `#tests::foo`).
240fn path_leaf(key: &str) -> (String, String) {
241    let after = key.strip_prefix("sym:rust:").unwrap_or(key);
242    match after.split_once('#') {
243        Some((path, qual)) => (
244            path.to_owned(),
245            qual.rsplit("::").next().unwrap_or(qual).to_owned(),
246        ),
247        None => (after.to_owned(), String::new()),
248    }
249}
250
251/// Comparable Rust symbol keys from the snapshot (functions/structs/enums/traits
252/// in `.rs` files, matching Roteiro's extraction scope).
253fn codegraph_symbols(conn: &Connection) -> Result<BTreeSet<String>, OracleError> {
254    let mut stmt = conn.prepare(
255        "SELECT f.path, COALESCE(n.qualified_name, n.name)
256         FROM nodes n JOIN files f ON f.id = n.file_id
257         WHERE n.type IN ('function','struct','enum','trait')
258           AND f.path LIKE '%.rs'",
259    )?;
260    let rows = stmt.query_map([], |r| {
261        Ok(symbol_key(&r.get::<_, String>(0)?, &r.get::<_, String>(1)?))
262    })?;
263    let mut set = BTreeSet::new();
264    for row in rows {
265        set.insert(row?);
266    }
267    Ok(set)
268}
269
270/// How many constants codegraph extracted from Rust files (a Roteiro gap).
271fn codegraph_constant_count(conn: &Connection) -> Result<usize, OracleError> {
272    let n: i64 = conn.query_row(
273        "SELECT COUNT(*) FROM nodes n JOIN files f ON f.id = n.file_id
274         WHERE n.type = 'constant' AND f.path LIKE '%.rs'",
275        [],
276        |r| r.get(0),
277    )?;
278    Ok(usize::try_from(n).unwrap_or(0))
279}
280
281/// Internal function→function `calls` from the snapshot, as Roteiro key pairs,
282/// restricted to calls whose endpoints are both comparable Rust symbols.
283fn codegraph_calls(
284    conn: &Connection,
285    symbols: &BTreeSet<String>,
286) -> Result<BTreeSet<(String, String)>, OracleError> {
287    let mut stmt = conn.prepare(
288        "SELECT sf.path, COALESCE(s.qualified_name, s.name),
289                tf.path, COALESCE(t.qualified_name, t.name)
290         FROM edges e
291         JOIN nodes s ON s.id = e.source_id JOIN files sf ON sf.id = s.file_id
292         JOIN nodes t ON t.id = e.target_id JOIN files tf ON tf.id = t.file_id
293         WHERE e.relation = 'calls'
294           AND s.type = 'function' AND t.type = 'function'
295           AND sf.path LIKE '%.rs' AND tf.path LIKE '%.rs'",
296    )?;
297    let rows = stmt.query_map([], |r| {
298        let src = symbol_key(&r.get::<_, String>(0)?, &r.get::<_, String>(1)?);
299        let dst = symbol_key(&r.get::<_, String>(2)?, &r.get::<_, String>(3)?);
300        Ok((src, dst))
301    })?;
302    let mut set = BTreeSet::new();
303    for row in rows {
304        let (src, dst) = row?;
305        // Only compare calls whose endpoints codegraph itself emitted as symbols.
306        if symbols.contains(&src) && symbols.contains(&dst) {
307            set.insert((src, dst));
308        }
309    }
310    Ok(set)
311}
312
313#[cfg(test)]
314mod tests {
315    use super::{OracleError, compare};
316    use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
317    use rusqlite::Connection;
318
319    /// Build a minimal codegraph-schema snapshot at `path`.
320    fn write_snapshot(path: &std::path::Path) {
321        let conn = Connection::open(path).expect("open");
322        conn.execute_batch(
323            "CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT);
324             CREATE TABLE nodes (id INTEGER PRIMARY KEY, file_id INTEGER, type TEXT,
325                 name TEXT, qualified_name TEXT);
326             CREATE TABLE edges (id INTEGER PRIMARY KEY, source_id INTEGER,
327                 target_id INTEGER, relation TEXT);
328             CREATE TABLE meta (key TEXT, value TEXT);
329             INSERT INTO meta VALUES ('snapshot_source_commit', 'abc123');
330             INSERT INTO files VALUES (1, 'src/lib.rs');
331             -- Shared: Store (struct), Store.open (method), helper (fn).
332             INSERT INTO nodes VALUES (1, 1, 'struct',   'Store',  'Store');
333             INSERT INTO nodes VALUES (2, 1, 'function', 'open',   'Store.open');
334             INSERT INTO nodes VALUES (3, 1, 'function', 'helper', 'helper');
335             -- codegraph-only: a constant (Roteiro gap) and an extra fn.
336             INSERT INTO nodes VALUES (4, 1, 'constant', 'MAX',    'MAX');
337             INSERT INTO nodes VALUES (5, 1, 'function', 'only_cg','only_cg');
338             -- scope difference: codegraph keys a test fn bare, Roteiro scopes it.
339             INSERT INTO nodes VALUES (6, 1, 'function', 'foo',    'foo');
340             -- calls: Store.open -> helper.
341             INSERT INTO edges VALUES (1, 2, 3, 'calls');",
342        )
343        .expect("seed");
344    }
345
346    #[test]
347    fn compares_symbols_and_calls() {
348        let dir = std::env::temp_dir().join(format!("roteiro-oracle-{}", std::process::id()));
349        std::fs::create_dir_all(&dir).expect("mkdir");
350        let db = dir.join("cg.db");
351        write_snapshot(&db);
352
353        // Roteiro side: shares Store, Store::open, helper; has its own `roteiro_only`
354        // fn; lacks `only_cg` and the constant. The call open->helper matches.
355        let mut store = Store::open_in_memory().expect("store");
356        let facts = FactSet::new()
357            .with_node(Node::new(
358                "sym:rust:src/lib.rs#Store",
359                NodeKind::Struct,
360                "Store",
361            ))
362            .with_node(Node::new(
363                "sym:rust:src/lib.rs#Store::open",
364                NodeKind::Fn,
365                "open",
366            ))
367            .with_node(Node::new(
368                "sym:rust:src/lib.rs#helper",
369                NodeKind::Fn,
370                "helper",
371            ))
372            .with_node(Node::new(
373                "sym:rust:src/lib.rs#roteiro_only",
374                NodeKind::Fn,
375                "roteiro_only",
376            ))
377            // Same `foo` codegraph found, but scoped under the test module.
378            .with_node(Node::new(
379                "sym:rust:src/lib.rs#tests::foo",
380                NodeKind::Fn,
381                "foo",
382            ))
383            .with_edge(Edge::derived(
384                "sym:rust:src/lib.rs#Store::open",
385                "sym:rust:src/lib.rs#helper",
386                EdgeKind::Calls,
387            ));
388        store.apply_factset(&facts).expect("apply");
389
390        let report = compare(&db, &store).expect("compare");
391        assert_eq!(report.source_commit.as_deref(), Some("abc123"));
392        // 5 comparable codegraph symbols (Store, Store::open, helper, only_cg, foo).
393        assert_eq!(report.symbols_codegraph, 5);
394        assert_eq!(report.symbols_roteiro, 5);
395        assert_eq!(report.symbols_matched, 3, "Store, Store::open, helper");
396        assert_eq!(report.symbols_scope_diff, 1, "foo vs tests::foo");
397        assert_eq!(report.codegraph_only, 1, "only_cg (genuine)");
398        assert_eq!(report.roteiro_only, 1, "roteiro_only (genuine)");
399        assert_eq!(
400            report.codegraph_only_sample,
401            vec!["sym:rust:src/lib.rs#only_cg"]
402        );
403        assert_eq!(report.constants_codegraph, 1, "MAX is a Roteiro gap");
404        // The one internal call is present in both.
405        assert_eq!(report.calls_codegraph, 1);
406        assert_eq!(report.calls_agree, 1);
407        assert_eq!(report.calls_codegraph_only, 0);
408
409        std::fs::remove_dir_all(&dir).ok();
410    }
411
412    /// When a file has more same-leaf symbols on one side than the other, the
413    /// surplus is a genuine gap — not silently absorbed as a scope diff.
414    #[test]
415    fn multiplicity_surplus_is_a_genuine_gap() {
416        let dir = std::env::temp_dir().join(format!("roteiro-oracle-mult-{}", std::process::id()));
417        std::fs::create_dir_all(&dir).expect("mkdir");
418        let db = dir.join("cg.db");
419        let conn = Connection::open(&db).expect("open");
420        conn.execute_batch(
421            "CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT);
422             CREATE TABLE nodes (id INTEGER PRIMARY KEY, file_id INTEGER, type TEXT,
423                 name TEXT, qualified_name TEXT);
424             CREATE TABLE edges (id INTEGER PRIMARY KEY, source_id INTEGER,
425                 target_id INTEGER, relation TEXT);
426             CREATE TABLE meta (key TEXT, value TEXT);
427             INSERT INTO files VALUES (1, 'src/lib.rs');
428             -- codegraph: two `run` functions under different (dropped) scopes.
429             INSERT INTO nodes VALUES (1, 1, 'function', 'run', 'a.run');
430             INSERT INTO nodes VALUES (2, 1, 'function', 'run', 'b.run');",
431        )
432        .expect("seed");
433
434        // Roteiro has only one `run` (leaf `run`) in that file, under a scope
435        // that matches neither codegraph key exactly.
436        let mut store = Store::open_in_memory().expect("store");
437        store
438            .apply_factset(&FactSet::new().with_node(Node::new(
439                "sym:rust:src/lib.rs#tests::run",
440                NodeKind::Fn,
441                "run",
442            )))
443            .expect("apply");
444
445        let report = compare(&db, &store).expect("compare");
446        // One leaf pair is a scope diff; the extra codegraph `run` is a real gap.
447        assert_eq!(report.symbols_scope_diff, 1);
448        assert_eq!(
449            report.codegraph_only, 1,
450            "the surplus `run` is a genuine gap"
451        );
452        assert_eq!(report.roteiro_only, 0);
453
454        std::fs::remove_dir_all(&dir).ok();
455    }
456
457    #[test]
458    fn rejects_non_codegraph_db() {
459        let dir = std::env::temp_dir().join(format!("roteiro-oracle-bad-{}", std::process::id()));
460        std::fs::create_dir_all(&dir).expect("mkdir");
461        let db = dir.join("plain.db");
462        let conn = Connection::open(&db).expect("open");
463        conn.execute_batch("CREATE TABLE whatever (x INTEGER);")
464            .expect("seed");
465        drop(conn);
466
467        let store = Store::open_in_memory().expect("store");
468        let err = compare(&db, &store).expect_err("should reject");
469        assert!(matches!(err, OracleError::NotCodegraph(_)));
470
471        std::fs::remove_dir_all(&dir).ok();
472    }
473}