1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10fn 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
19pub 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)]
47pub 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)]
60pub struct DoctorAgent {
62 pub id: String,
63 pub present: bool,
64 pub detail: String,
65}
66
67#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
68pub 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
80pub 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 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 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 (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 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 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 (
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 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 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}