Skip to main content

scc_engine/
integrations.rs

1//! Engine integrations: adapter listing + doctor report VALUES.
2//!
3//! The report derivation (registry rows, contribution signals, handshake
4//! policy) moves verbatim from `scc-cli` commands.rs; transports render it.
5//! Byte-identical: same rows, same warnings, same order.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10// trace:exempt reason=internal-detail
11fn adapter_scope(name: &str) -> &'static str {
12    match name {
13        "context7" => "network+subprocess(npx)",
14        "serena" | "beads" | "cbm" | "hindsight" | "scip" | "gitnexus" | "narsil" => "filesystem",
15        _ => "filesystem",
16    }
17}
18
19// trace:exempt reason=internal-detail
20pub fn list(root: &Path) -> crate::Result<Vec<(String, &'static str)>> {
21    use scc_indexer::adapters::IntegrationCategory;
22    let config = crate::workspace::load_config(root)?;
23    let enabled = |key: Option<&str>| -> bool {
24        match key {
25            None => true,
26            Some("serena") => config.integrations.serena,
27            Some("beads") => config.integrations.beads,
28            Some("hindsight") => config.integrations.hindsight,
29            Some("gitnexus") => config.integrations.gitnexus,
30            Some("context7_command") => !config.integrations.context7_command.is_empty(),
31            Some(_) => false,
32        }
33    };
34    Ok(scc_indexer::adapters::integration_registry()
35        .into_iter()
36        .filter(|d| {
37            d.category != IntegrationCategory::CompatibilityOnly
38                && d.category != IntegrationCategory::InternalPass
39                && d.category != IntegrationCategory::AgentIntegration
40                && enabled(d.config_key)
41        })
42        .map(|d| (d.id.to_string(), adapter_scope(d.id)))
43        .collect())
44}
45
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47// trace:exempt reason=internal-detail
48pub struct DoctorRow {
49    pub id: String,
50    pub category: String,
51    pub mode: String,
52    pub implemented: bool,
53    pub configured: bool,
54    pub reachable: Option<bool>,
55    pub contributing: bool,
56    pub detail: String,
57}
58
59#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
60// trace:exempt reason=internal-detail
61pub struct DoctorAgent {
62    pub id: String,
63    pub present: bool,
64    pub detail: String,
65}
66
67#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
68// trace:exempt reason=internal-detail
69pub struct DoctorReport {
70    pub scc_version: String,
71    pub revision: String,
72    pub stale_files: usize,
73    pub dangling_edges: usize,
74    pub stats: HashMap<String, u64>,
75    pub integrations: Vec<DoctorRow>,
76    pub agents: Vec<DoctorAgent>,
77    pub warnings: u32,
78}
79
80// trace:exempt reason=internal-detail
81pub fn doctor_report(
82    store: &scc_store::Store,
83    config: &scc_indexer::Config,
84    root: &Path,
85    deep: bool,
86    network: bool,
87) -> crate::Result<DoctorReport> {
88    use scc_indexer::adapters::integration_registry;
89    let stale = crate::workspace::stale_paths(store).unwrap_or_default();
90    let mut rows: Vec<DoctorRow> = Vec::new();
91        let mut warnings: u32 = 0;
92
93        // Evidence already in the graph, by extractor tag (the honest
94        // contribution signal): native facts carry extractor `scc-native`;
95        // TraceLayer facts carry requirement/decision/implementation/test/work
96        // entity kinds (the importer emits no extractor tag); beads/cbm/
97        // hindsight/gitnexus carry their kinds.
98        let ev_by_extractor: std::collections::BTreeMap<String, u64> = store
99            .all_evidence()
100            .map(|evs| {
101                let mut m = std::collections::BTreeMap::new();
102                for e in evs {
103                    if let Some(x) = e.extractor.as_deref() {
104                        *m.entry(x.to_string()).or_insert(0) += 1;
105                    }
106                }
107                m
108            })
109            .unwrap_or_default();
110        let count_kind = |kind: &str| -> u64 {
111            store.entities_by_kind(kind).map(|v| v.len() as u64).unwrap_or(0)
112        };
113        let on_path = |bin: &str| -> bool {
114            std::env::var_os("PATH")
115                .map(|p| {
116                    std::env::split_paths(&p).any(|d| {
117                        d.join(bin).is_file() || d.join(format!("{bin}.exe")).is_file()
118                    })
119                })
120                .unwrap_or(false)
121        };
122
123        // CORE section facts (not registry rows).
124        let scc_version = env!("CARGO_PKG_VERSION");
125        let stats = store.stats().unwrap_or_default();
126        let rev = store
127            .snapshot_status()
128            .ok()
129            .flatten()
130            .map(|(s, _)| s.revision.to_string())
131            .unwrap_or_else(|| "unindexed".into());
132        let dangling = scc_graph::RealityGraph::load(store)
133            .map(|g| {
134                g.all_rels()
135                    .iter()
136                    .filter(|r| {
137                        let known = |id: &str| {
138                            g.entities.contains_key(id)
139                                || id.contains("/external_api/")
140                                || id.contains("/component/")
141                                || id.contains("/flow/")
142                                || id.contains("/invariant/")
143                        };
144                        !known(&r.subject) || !known(&r.object)
145                    })
146                    .count()
147            })
148            .unwrap_or(0);
149
150        for d in integration_registry() {
151            let (configured, reachable, contributing, detail) = match d.id {
152                "native" => {
153                    let n: u64 = stats.get("entities").copied().unwrap_or(0);
154                    (true, None, n > 0, format!("{n} entities in graph"))
155                }
156                "scip" | "ccg" | "cbm" => {
157                    // On-demand file import, always available, needs no config:
158                    // an availability note, never a warning.
159                    (false, None, false, format!("importer available (on-demand `scc import {}`)", d.id))
160                }
161                "gitnexus" => {
162                    let n = count_kind("symbol")
163                        + ev_by_extractor.get("gitnexus").copied().unwrap_or(0);
164                    let cli = on_path("gitnexus");
165                    (
166                        config.integrations.gitnexus,
167                        None,
168                        n > 0 && config.integrations.gitnexus,
169                        format!(
170                            "mode: file import; configured: {}; CLI detected: {}; symbol-ish evidence: {n}",
171                            config.integrations.gitnexus, cli
172                        ),
173                    )
174                }
175                "tracelayer" => {
176                    let n = count_kind("requirement")
177                        + count_kind("decision")
178                        + count_kind("implementation")
179                        + count_kind("test")
180                        + count_kind("work");
181                    (
182                        true,
183                        None,
184                        n > 0,
185                        format!("{n} trace facts (requirement/decision/implementation/test/work); last import: revision {rev}"),
186                    )
187                }
188                "beads" => {
189                    let n = count_kind("task");
190                    (
191                        config.integrations.beads,
192                        None,
193                        n > 0 && config.integrations.beads,
194                        format!("task-state entities: {n}; configured: {}", config.integrations.beads),
195                    )
196                }
197                "hindsight" => {
198                    let n = count_kind("lesson");
199                    (
200                        config.integrations.hindsight,
201                        None,
202                        n > 0 && config.integrations.hindsight,
203                        format!("lessons in graph: {n}; bank: .scc/lessons.jsonl"),
204                    )
205                }
206                "context7" => {
207                    let cfg = !config.integrations.context7_command.is_empty();
208                    let reach = if network {
209                        // Explicit opt-in only: never probed by default.
210                        Some(config.integrations.context7_command.contains("context7"))
211                    } else {
212                        None
213                    };
214                    if cfg && reach == Some(false) {
215                        warnings += 1;
216                    }
217                    (
218                        cfg,
219                        reach,
220                        false,
221                        if cfg {
222                            "configured; handshake only under --network (never auto-downloads)".into()
223                        } else {
224                            "disabled (empty context7_command)".into()
225                        },
226                    )
227                }
228                "lsp-pyright" => {
229                    let bin = on_path("pyright") || on_path("basedpyright");
230                    let reach = if deep {
231                        // --deep may handshake locally; default only reports
232                        // the binary presence (offline, read-only).
233                        Some(bin && std::process::Command::new("pyright").arg("--version").output().map(|o| o.status.success()).unwrap_or(false))
234                    } else {
235                        None
236                    };
237                    if !bin {
238                        warnings += 1;
239                    }
240                    (true, reach, bin, if bin { "binary on PATH (resolver available)".into() } else { "not installed".into() })
241                }
242                "lsp-tsserver" => {
243                    let bin = on_path("tsserver") || on_path("typescript-language-server");
244                    if !bin {
245                        warnings += 1;
246                    }
247                    (true, if deep { Some(bin) } else { None }, bin, if bin { "binary on PATH (resolver available)".into() } else { "not installed".into() })
248                }
249                "runtime" => {
250                    (false, None, false, "ingestion source available (`scc ingest`)".into())
251                }
252                "serena" => {
253                    // Compatibility-only: presence is coexistence info, never
254                    // a warning either way.
255                    (
256                        false,
257                        None,
258                        false,
259                        "compatibility only; no SCC evidence adapter (coexistence/exact-source workflow)".into(),
260                    )
261                }
262                "configrefs" | "failures" => (true, None, true, "internal post-pass; always runs at index".into()),
263                _ => (false, None, false, "unknown integration".into()),
264            };
265            // Unconfigured-but-capable importers are availability notes, not
266            // warnings; missing optionals (pyright binary) and failed
267            // handshakes warn; strict promotes every non-contributing
268            // configured row to a warning at report time (counted below).
269            if matches!(d.id, "gitnexus" | "beads" | "hindsight") && configured && !contributing {
270                warnings += 1;
271            }
272            rows.push(DoctorRow {
273                id: d.id.to_string(),
274                category: d.category.as_str().into(),
275                mode: d.mode.to_string(),
276                implemented: true,
277                configured,
278                reachable,
279                contributing,
280                detail,
281            });
282        }
283
284        // Agent integrations: presence probes over install artifacts (local,
285        // offline). Hermes installs outside the repo, so absence is a note.
286        let home = std::env::var_os("HOME").map(PathBuf::from);
287        let agent_rows: Vec<DoctorAgent> = vec![
288            DoctorAgent { id: "omp".into(), present: root.join(".omp").is_dir() || root.join("plugins/omp/scc").is_dir(), detail: "repo .omp/ or plugin dir".into() },
289            DoctorAgent { id: "claude-code".into(), present: root.join("plugins/claude").is_dir(), detail: "repo plugin dir".into() },
290            DoctorAgent { id: "codex".into(), present: root.join("AGENTS.md").is_file(), detail: "AGENTS.md present".into() },
291            DoctorAgent { id: "opencode".into(), present: root.join("plugins/opencode").is_dir(), detail: "repo plugin dir".into() },
292            DoctorAgent { id: "hermes".into(), present: home.as_ref().map(|h| h.join(".hermes").is_dir()).unwrap_or(false) || root.join("plugins/hermes").is_dir(), detail: "home or repo plugin".into() },
293            DoctorAgent { id: "mcp-server".into(), present: root.join(".mcp.json").is_file() || root.join("opencode.json").is_file(), detail: "MCP config present".into() },
294        ];
295    Ok(DoctorReport {
296        scc_version: scc_version.into(),
297        revision: rev,
298        stale_files: stale.len(),
299        dangling_edges: dangling,
300        stats,
301        integrations: rows,
302        agents: agent_rows,
303        warnings,
304    })
305}