Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

dwarves-gui/src/graph_source.rs

//! **The graph SOURCE pane** — click a node label in a graph, read the source behind it.
//!
//! The owner, 2026-08-25: *"dataview-call edges ser underbar ut. lägg till labels klickbara
//! och då visar du källkoden bakom hela grafen eller i en pane till höger."*
//!
//! ## One question to the room
//! The graph pane is [`facett_graph3d::Graph3D`], mounted by
//! [`facett_warehousedeck::WarehouseDeck`]; the Data view reads back which node was
//! clicked. This pane then ASKS nornir's code room, through the forge, for that node:
//! `Knowledge.Source { moniker }` answers where the item is (repo, file, its whole span)
//! and the TEXT of the span — read by the server from the commit the room was consumed
//! at, so the line numbers are the lines the spans count.
//!
//! Nothing is resolved here and no file is read here. The pane used to keep its own
//! index of `scip_occurrences` / `symbol_facts` rows, pick a definition by name, and
//! read the lines from a checkout on the machine the UI runs on. Those tables have no
//! writer, a name is not an identity, and the laptop's tree is not the server's.
//!
//! ## Node ids are NOT all symbols
//! A node means what its pane says it means:
//!
//! | graph pane | node is | the pane shows |
//! |---|---|---|
//! | the code room's call graph | an item of the room (it carries its moniker) | the item's span and text |
//! | `dep_graph_edges` | a REPO name | that a repo has no single source span |
//!
//! ## States that must never draw alike
//! 1. **nothing selected** — says so and names the gesture that fills it;
//! 2. **asking** — the question is in flight, off the UI thread;
//! 3. **an answer that is not a measurement** (`not_consumed`, `unreachable`, a node with
//!    no span …) — the state as a chip and the reader's sentence, no code;
//! 4. **located, text missing** — the file and span, and why the text could not be read;
//! 5. **source** — the only state that paints code, under `repo/file:start-end @ commit`.

use std::sync::Arc;

use crate::room::{self, Pending, RoomAnswer, RoomSource};

/// The surface this pane asks.
pub const SURFACE: &str = "Knowledge.Source";

/// Lines of source the pane asks for per item. A span longer than this comes back cut,
/// and the answer says so (`truncated`) — the pane prints it.
pub const MAX_SLICE_LINES: usize = 400;

/// Default width of the right-hand source panel, in points. Resizable from
/// [`WIDTH_RANGE`] — the owner asked for "att fönstret kan bli större".
pub const DEFAULT_WIDTH: f32 = 460.0;

/// The drag range of the source panel.
pub const WIDTH_RANGE: std::ops::RangeInclusive<f32> = 260.0..=1400.0;

/// What KIND of thing a graph pane's node labels are. Derived from the pane's source,
/// never guessed from the label's own text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeKind {
    /// An item of the code room (the call graph).
    Symbol,
    /// A repo name (`dep_graph_edges`).
    Repo,
}

/// The node kind a warehouse edge TABLE's labels carry, or `None` for a table this pane
/// has no reading of. (The code room's call graph is not a table: its nodes are
/// [`NodeKind::Symbol`] by construction.)
pub fn node_kind_for(table: &str) -> Option<NodeKind> {
    match table {
        "dep_graph_edges" => Some(NodeKind::Repo),
        _ => None,
    }
}

/// One clicked node: its label, the room's id for it when it has one, and what it is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeRef {
    pub label: String,
    /// The room's moniker of the item (`None` for a node that is not an item).
    pub moniker: Option<String>,
    pub kind: NodeKind,
}

/// What the pane is showing for the selection.
#[derive(Debug, Clone, PartialEq)]
pub enum Resolution {
    /// No node has been clicked yet.
    Idle,
    /// A repo node: there is no single source span to show.
    RepoNode { node: String },
    /// The question is in flight.
    Asking { node: String },
    /// The room's answer (any state).
    Answered { node: String, answer: RoomAnswer },
}

impl Resolution {
    /// A stable state name for the robot oracle + the render gate.
    pub fn kind(&self) -> String {
        match self {
            Resolution::Idle => "idle".into(),
            Resolution::RepoNode { .. } => "repo_node".into(),
            Resolution::Asking { .. } => "asking".into(),
            Resolution::Answered { answer, .. } if !answer.is_measured() => answer.state.clone(),
            Resolution::Answered { answer, .. } if answer.rows("lines").is_empty() => {
                "located_no_text".into()
            }
            Resolution::Answered { .. } => "source".into(),
        }
    }
    /// The selected node, if any.
    pub fn node(&self) -> Option<&str> {
        match self {
            Resolution::Idle => None,
            Resolution::RepoNode { node }
            | Resolution::Asking { node }
            | Resolution::Answered { node, .. } => Some(node),
        }
    }
}

/// `repo/file:start-end @ commit` of an answer that located the item.
pub fn heading(a: &RoomAnswer) -> Option<String> {
    let b = &a.body;
    let (repo, file) = (b["repo"].as_str()?, b["file"].as_str()?);
    let (start, end) = (
        b["start_line"].as_u64()?,
        b["end_line"].as_u64().unwrap_or(0),
    );
    let span = if end > start {
        format!("{start}-{end}")
    } else {
        start.to_string()
    };
    Some(match b["sha"].as_str() {
        Some(sha) => format!("{repo}/{file}:{span} @ {}", &sha[..sha.len().min(10)]),
        None => format!("{repo}/{file}:{span}"),
    })
}

// ── the pane ──────────────────────────────────────────────────────────────────

/// **The right-hand source pane.** Holds the selection and the room's answer for it;
/// [`GraphSourcePane::view`] paints it and [`GraphSourcePane::show_beside`] puts it in a
/// RESIZABLE right panel with the graph wall on its left.
pub struct GraphSourcePane {
    /// Where the pane asks. `None` ⇒ the host gave it no room: a selection then says so.
    room: Option<Arc<dyn RoomSource>>,
    selection: Option<NodeRef>,
    resolution: Resolution,
    pending: Option<Pending<RoomAnswer>>,
    /// Panel width in points — observable so a test can prove the drag range is live.
    width: f32,
    /// The repo the ask is scoped to (`Knowledge.Source { repo }`) — set by
    /// [`select_scoped`](Self::select_scoped); `None` for a plain [`select`](Self::select).
    scope_repo: Option<String>,
    /// Inclusive line ranges (file lines) drawn HIGHLIGHTED — a group twin's
    /// statement group inside its function. Empty = nothing highlighted.
    highlight: Vec<(u64, u64)>,
    /// Scroll the first highlighted line into view on the next paint.
    scroll_pending: bool,
}

impl Default for GraphSourcePane {
    fn default() -> Self {
        Self::new()
    }
}

impl GraphSourcePane {
    pub fn new() -> Self {
        Self {
            room: None,
            selection: None,
            resolution: Resolution::Idle,
            pending: None,
            width: DEFAULT_WIDTH,
            scope_repo: None,
            highlight: Vec::new(),
            scroll_pending: false,
        }
    }

    /// The pane asking `room`.
    pub fn with_room(mut self, room: Arc<dyn RoomSource>) -> Self {
        self.room = Some(room);
        self
    }

    /// The wall was read again (a new warehouse generation): the answer on screen is
    /// about the previous state, so the selection is asked again.
    pub fn reread(&mut self) {
        if let Some(sel) = self.selection.take() {
            self.pending = None;
            self.resolution = Resolution::Idle;
            self.select(sel);
        }
    }

    /// **The click.** Select `node` and ask the room for its source, off the UI thread.
    /// Selecting the same node twice is idempotent, so the deck poll may call this every
    /// frame without a second question.
    pub fn select(&mut self, node: NodeRef) {
        if self.selection.as_ref() == Some(&node) && self.scope_repo.is_none() && self.highlight.is_empty() {
            return;
        }
        self.scope_repo = None;
        self.highlight.clear();
        self.scroll_pending = false;
        self.ask(node);
    }

    /// **The click, scoped**: ask for `node` IN `repo` (the server opens that repo's
    /// room, so a path names the right item) and draw `highlight` (inclusive file
    /// line ranges) marked, scrolled to its first line. Idempotent like
    /// [`select`](Self::select).
    pub fn select_scoped(&mut self, node: NodeRef, repo: Option<String>, highlight: Vec<(u64, u64)>) {
        if self.selection.as_ref() == Some(&node) && self.scope_repo == repo && self.highlight == highlight {
            return;
        }
        self.scope_repo = repo;
        self.scroll_pending = !highlight.is_empty();
        self.highlight = highlight;
        self.ask(node);
    }

    /// Is file line `line` in a highlighted range?
    pub fn is_highlighted(&self, line: u64) -> bool {
        self.highlight.iter().any(|(a, b)| (*a..=*b).contains(&line))
    }

    fn ask(&mut self, node: NodeRef) {
        self.pending = None;
        self.resolution = match (&node.kind, &self.room) {
            (NodeKind::Repo, _) => Resolution::RepoNode {
                node: node.label.clone(),
            },
            (NodeKind::Symbol, None) => Resolution::Answered {
                node: node.label.clone(),
                answer: {
                    let mut a = RoomAnswer::pending(SURFACE, "(no room source)");
                    a.note = "this pane was given no code room to ask".to_string();
                    a
                },
            },
            (NodeKind::Symbol, Some(room)) => {
                let (room, ask, repo) = (Arc::clone(room), node.clone(), self.scope_repo.clone());
                self.pending = Some(Pending::spawn("graph-source-ask", move || {
                    let mut params = match &ask.moniker {
                        Some(m) => {
                            serde_json::json!({ "moniker": m, "max_lines": MAX_SLICE_LINES })
                        }
                        None => {
                            serde_json::json!({ "name": ask.label, "max_lines": MAX_SLICE_LINES })
                        }
                    };
                    if let Some(r) = repo {
                        params["repo"] = serde_json::json!(r);
                    }
                    room.ask(SURFACE, &params)
                }));
                Resolution::Asking {
                    node: node.label.clone(),
                }
            }
        };
        self.selection = Some(node);
    }

    /// Clear the selection (a click on empty canvas).
    pub fn clear(&mut self) {
        self.selection = None;
        self.pending = None;
        self.resolution = Resolution::Idle;
    }

    fn land(&mut self, res: Result<RoomAnswer, ()>) {
        let Some(sel) = &self.selection else { return };
        let answer = res.unwrap_or_else(|()| match &self.room {
            Some(r) => room::worker_died(SURFACE, &**r),
            None => RoomAnswer::pending(SURFACE, "(no room source)"),
        });
        self.resolution = Resolution::Answered {
            node: sel.label.clone(),
            answer,
        };
    }

    /// Non-blocking: land the in-flight answer if the worker finished. Called once/frame.
    pub fn poll(&mut self) {
        let Some(p) = &self.pending else { return };
        if let Some(res) = p.try_take() {
            self.pending = None;
            self.land(res);
        }
    }

    /// Blocking: wait for the in-flight answer (a scripted caller; the window never does).
    pub fn wait(&mut self) {
        if let Some(p) = self.pending.take() {
            let res = p.wait();
            self.land(res);
        }
    }

    pub fn is_pending(&self) -> bool {
        self.pending.is_some()
    }

    pub fn resolution(&self) -> &Resolution {
        &self.resolution
    }

    /// The panel's current width in points.
    pub fn width(&self) -> f32 {
        self.width
    }

    /// The robot / test oracle. Every branch the render can take is readable as data.
    pub fn state_json(&self) -> serde_json::Value {
        let answer = match &self.resolution {
            Resolution::Answered { answer, .. } => Some(answer),
            _ => None,
        };
        let lines = answer.map(|a| a.rows("lines")).unwrap_or(&[]);
        let first = answer.and_then(|a| a.body["start_line"].as_u64()).unwrap_or(1);
        let highlighted = answer
            .filter(|a| a.is_measured())
            .map(|_| (0..lines.len() as u64).filter(|i| self.is_highlighted(first + i)).count());
        serde_json::json!({
            "kind": "graph_source",
            "state": self.resolution.kind(),
            "node": self.resolution.node(),
            "moniker": self.selection.as_ref().and_then(|s| s.moniker.clone()),
            // where the room says the item is (null until an answer located it)
            "heading": answer.and_then(heading),
            "repo": answer.and_then(|a| a.body["repo"].as_str()),
            "file": answer.and_then(|a| a.body["file"].as_str()),
            "span": answer.and_then(|a| Some([a.body["start_line"].as_u64()?, a.body["end_line"].as_u64()?])),
            "sha": answer.and_then(|a| a.body["sha"].as_str()),
            "path": answer.and_then(|a| a.body["node"]["path"].as_str()),
            // the text as the server read it: how many lines, and the first and last
            "lines": answer.filter(|a| a.is_measured()).map(|_| lines.len()),
            "first_line": lines.first().and_then(|l| l.as_str()),
            "last_line": lines.last().and_then(|l| l.as_str()),
            "truncated": answer.is_some_and(|a| a.body["truncated"].as_bool() == Some(true)),
            "room": answer.map(RoomAnswer::state_json),
            "pending": self.pending.is_some(),
            "width": self.width,
            // the repo the ask was scoped to, and the marked lines (a group twin's group)
            "repo_scope": self.scope_repo,
            "highlight": self.highlight.iter().map(|(a, b)| [*a, *b]).collect::<Vec<_>>(),
            "highlighted_lines": highlighted,
        })
    }

    /// The id of the right-hand panel inside `ui`. ONE writer, so the render gate asks for
    /// the resize handle under exactly the id [`show_beside`](Self::show_beside) creates —
    /// a test that spells the id again is a test of its own spelling.
    pub fn panel_id(ui: &egui::Ui) -> egui::Id {
        ui.id().with("graph_source_panel")
    }

    /// The id egui registers the panel's DRAG HANDLE under. Present in a frame only when
    /// the panel was shown `.resizable(true)`; that is what the gate asserts.
    pub fn resize_handle_id(ui: &egui::Ui) -> egui::Id {
        Self::panel_id(ui).with("__resize")
    }

    /// **The split.** Paints `graph` on the left and this pane in a RESIZABLE right panel.
    /// `egui::Panel::show_inside` is deprecated in egui 0.35 (renamed to `show`), so this
    /// uses the current name — the left half is a `CentralPanel`, which is what makes the
    /// divider a real drag handle rather than a fixed column.
    pub fn show_beside(&mut self, ui: &mut egui::Ui, graph: impl FnOnce(&mut egui::Ui)) {
        let id = Self::panel_id(ui);
        let inner = egui::Panel::right(id)
            .resizable(true)
            .default_size(self.width)
            .size_range(WIDTH_RANGE)
            .show(ui, |ui| {
                self.view(ui);
                ui.min_rect().width()
            });
        self.width = inner.inner.max(*WIDTH_RANGE.start());
        egui::CentralPanel::default().show(ui, graph);
    }

    /// Paint the pane's current state — see the module header for the states.
    pub fn view(&mut self, ui: &mut egui::Ui) {
        self.poll();
        if self.pending.is_some() {
            ui.ctx().request_repaint();
        }
        ui.horizontal_wrapped(|ui| {
            ui.strong("📄 source");
            ui.label(
                egui::RichText::new("click a node label in a graph pane")
                    .small()
                    .weak(),
            );
        });
        ui.separator();

        match &self.resolution {
            Resolution::Idle => {
                ui.label(
                    egui::RichText::new(NO_SELECTION)
                        .italics()
                        .color(ui.visuals().weak_text_color()),
                );
            }
            Resolution::RepoNode { node } => {
                ui.colored_label(
                    egui::Color32::from_rgb(0xc8, 0x8a, 0x22),
                    format!("{REPO_NODE} — {node}"),
                );
            }
            Resolution::Asking { node } => {
                ui.horizontal(|ui| {
                    ui.spinner();
                    ui.label(format!("{ASKING} — {node}"));
                });
            }
            Resolution::Answered { node, answer } => {
                ui.label(egui::RichText::new(node).small().monospace());
                if let Some(h) = heading(answer) {
                    ui.label(egui::RichText::new(h).monospace().strong());
                }
                // what the answer rests on: state, evidence, the room it was read from
                room::callout(ui, "source", answer);
                let lines = answer.rows("lines");
                if !answer.is_measured() || lines.is_empty() {
                    return;
                }
                let candidates = answer.body["candidates"].as_array().map_or(0, Vec::len);
                if candidates > 1 {
                    ui.label(
                        egui::RichText::new(format!("1 of {candidates} nodes share this name — showing the first definition with a span"))
                            .small()
                            .color(egui::Color32::from_rgb(0xc8, 0x8a, 0x22)),
                    );
                }
                ui.separator();
                let first = answer.body["start_line"].as_u64().unwrap_or(1);
                let width = ui.available_width();
                let mark = egui::Color32::from_rgba_unmultiplied(0xc8, 0x8a, 0x22, 0x50);
                let mut scroll = self.scroll_pending;
                egui::ScrollArea::both()
                    .auto_shrink([false, false])
                    .show(ui, |ui| {
                        ui.set_min_width(width);
                        for (i, text) in lines.iter().enumerate() {
                            let n = first + i as u64;
                            let lit = self.highlight.iter().any(|(a, b)| (*a..=*b).contains(&n));
                            let row = ui.horizontal(|ui| {
                                let mut num = egui::RichText::new(format!("{n:>5}")).monospace();
                                let mut code = egui::RichText::new(text.as_str().unwrap_or("")).monospace();
                                if lit {
                                    num = num.strong().background_color(mark);
                                    code = code.background_color(mark);
                                } else {
                                    num = num.weak();
                                }
                                ui.label(num);
                                ui.label(code);
                            });
                            if lit && scroll {
                                row.response.scroll_to_me(Some(egui::Align::Center));
                                scroll = false;
                            }
                        }
                    });
                self.scroll_pending = scroll;
            }
        }
    }
}

/// The non-source headings. Public because the render gate asserts on the exact strings
/// the AccessKit tree carries.
pub const NO_SELECTION: &str = "no node selected";
pub const ASKING: &str = "asking the code room for the source";
pub const REPO_NODE: &str =
    "a repo node — a repo has no single source span; click an item of the code room's call graph";

#[cfg(test)]
mod tests {
    use super::*;
    use crate::room::testing::{refusal, room as room_id, CannedRoom};
    use serde_json::json;

    const MONIKER: &str = "rust-analyzer cargo shop 0.1.0 pay/charge().";

    fn located() -> serde_json::Value {
        json!({
            "state": "complete",
            "node": { "path": "shop::pay::charge", "moniker": MONIKER, "kind": "fn", "crate": "shop", "repo": "shop", "file": "src/pay.rs", "line": 7, "end_line": 9, "defined": true },
            "candidates": [{ "path": "shop::pay::charge" }],
            "repo": "shop", "file": "src/pay.rs", "start_line": 7, "end_line": 9, "sha": "0123456789abcdef0123",
            "lines": ["pub fn charge(cart: &Cart) -> Receipt {", "    audit(cart);", "}"],
            "file_lines": 48, "truncated": false,
            "evidence": { "missing": [], "notes": [] },
            "room": room_id("w", "shop", 2, 31, 60),
        })
    }

    fn node() -> NodeRef {
        NodeRef {
            label: "shop::pay::charge".into(),
            moniker: Some(MONIKER.into()),
            kind: NodeKind::Symbol,
        }
    }

    fn pane(src: &Arc<CannedRoom>) -> GraphSourcePane {
        GraphSourcePane::new().with_room(src.clone())
    }

    /// The pane shows the SERVER's span and text for the clicked item — asked by the
    /// item's moniker, not by its name.
    #[test]
    fn a_clicked_item_shows_the_rooms_span_and_text() {
        let src = CannedRoom::new(&[(SURFACE, located())]);
        let mut p = pane(&src);
        assert_eq!(p.state_json()["state"], "idle");
        p.select(node());
        assert_eq!(p.state_json()["state"], "asking");
        p.wait();
        let s = p.state_json();
        assert_eq!(s["state"], "source");
        assert_eq!(s["heading"], "shop/src/pay.rs:7-9 @ 0123456789");
        assert_eq!(
            (
                s["file"].as_str(),
                s["span"][0].as_u64(),
                s["span"][1].as_u64(),
                s["lines"].as_u64()
            ),
            (Some("src/pay.rs"), Some(7), Some(9), Some(3))
        );
        assert_eq!(
            (s["first_line"].as_str(), s["last_line"].as_str()),
            (Some("pub fn charge(cart: &Cart) -> Receipt {"), Some("}"))
        );
        assert_eq!(
            s["room"]["room"],
            "workspace w · shop@2 (0123456789) · 31 nodes · 60 edges"
        );
        assert_eq!(
            src.asked.lock().unwrap().as_slice(),
            &[(
                SURFACE.to_string(),
                json!({ "moniker": MONIKER, "max_lines": MAX_SLICE_LINES })
            )]
        );
        // the same node again is not a second question
        p.select(node());
        assert_eq!(src.asked.lock().unwrap().len(), 1);
    }

    /// Located, text missing (the server could not read the file at that commit): the
    /// span is shown, the reader's sentence says why there is no text, and no line is
    /// invented.
    #[test]
    fn a_located_item_without_text_says_why() {
        let mut body = located();
        body["state"] = json!("partial");
        body["lines"] = json!([]);
        body["evidence"] = json!({ "missing": [{ "input": "source text", "repo": "shop", "why": "`src/pay.rs` at 0123456789 could not be read from the consumer's clone" }], "notes": [] });
        let src = CannedRoom::new(&[(SURFACE, body)]);
        let mut p = pane(&src);
        p.select(node());
        p.wait();
        let s = p.state_json();
        assert_eq!(
            (
                s["state"].as_str(),
                s["lines"].as_u64(),
                s["heading"].as_str()
            ),
            (
                Some("located_no_text"),
                Some(0),
                Some("shop/src/pay.rs:7-9 @ 0123456789")
            )
        );
        assert!(
            s["room"]["evidence"]
                .as_str()
                .unwrap()
                .contains("could not be read"),
            "{s}"
        );
        assert!(s["first_line"].is_null());
    }

    /// A refusal, a node with no span and an unreachable server are each their own
    /// state; none shows a location or a line.
    #[test]
    fn an_answer_that_is_not_a_measurement_shows_its_state_and_no_code() {
        for (body, want) in [
            (
                refusal("not_consumed", "repo `shop` has no consumer offset"),
                "not_consumed",
            ),
            (
                json!({ "state": "empty_gap", "node": {}, "candidates": [], "lines": [], "evidence": { "missing": [{ "input": "span", "repo": null, "why": "a reference stub: there is no file to show" }], "notes": [] } }),
                "empty_gap",
            ),
        ] {
            let src = CannedRoom::new(&[(SURFACE, body)]);
            let mut p = pane(&src);
            p.select(node());
            p.wait();
            let s = p.state_json();
            assert_eq!(s["state"], want);
            assert!(
                s["heading"].is_null() && s["first_line"].is_null() && s["lines"].is_null(),
                "{s}"
            );
        }
    }

    /// A repo node is not asked about at all, and says what it is.
    #[test]
    fn a_repo_node_is_not_an_item() {
        let src = CannedRoom::new(&[(SURFACE, located())]);
        let mut p = pane(&src);
        p.select(NodeRef {
            label: "znippy".into(),
            moniker: None,
            kind: NodeKind::Repo,
        });
        assert_eq!(p.state_json()["state"], "repo_node");
        assert!(src.asked.lock().unwrap().is_empty());
        p.clear();
        assert_eq!(p.state_json()["state"], "idle");
    }

    /// The rendered surface carries the state's own words (AccessKit), and the code only
    /// in the source state.
    #[test]
    fn the_rendered_pane_says_its_state() {
        use egui_kittest::kittest::Queryable;
        use egui_kittest::Harness;
        let src = CannedRoom::new(&[(SURFACE, located())]);
        let p = std::cell::RefCell::new(pane(&src));
        let mut h = Harness::builder()
            .with_size(egui::vec2(700.0, 500.0))
            .build_ui(|ui| p.borrow_mut().view(ui));
        h.run();
        assert!(h.query_by_label(NO_SELECTION).is_some());
        p.borrow_mut().select(node());
        p.borrow_mut().wait();
        h.run();
        assert!(
            h.query_by_label("shop/src/pay.rs:7-9 @ 0123456789")
                .is_some(),
            "the heading is drawn"
        );
        assert!(
            h.query_by_label("    audit(cart);").is_some(),
            "the server's text is drawn"
        );
        assert!(h.query_by_label(NO_SELECTION).is_none());
    }

    /// The only table whose labels this pane reads as nodes is the repo graph; the call
    /// graph is the room's, not a table.
    #[test]
    fn only_the_repo_graph_table_has_a_node_kind() {
        assert_eq!(node_kind_for("dep_graph_edges"), Some(NodeKind::Repo));
        for t in [
            "call_edges",
            "symbol_facts",
            "scip_occurrences",
            "git_heat_facts",
        ] {
            assert_eq!(node_kind_for(t), None, "{t}");
        }
    }
}