Skip to main content

scc_cli/
lib.rs

1//! System Context Compiler CLI, daemon, MCP server, and Claude Code plugin.
2
3pub mod agents_md;
4pub mod bench;
5pub mod benchagent;
6pub mod benchatlas;
7pub mod benchctx;
8pub mod benchloop;
9pub mod benchres;
10pub mod benchret;
11pub mod commands;
12pub mod compress;
13pub mod embed_cli;
14pub mod httpd;
15pub mod mcp;
16pub mod plugin;
17pub mod plugin_hermes;
18pub mod plugin_omp;
19pub mod viewer;
20pub mod resolve;
21
22use scc_context::ContextCompiler;
23use scc_graph::RealityGraph;
24use scc_indexer::Config;
25use scc_store::Store;
26use std::path::{Path, PathBuf};
27
28pub const SCC_DIR: &str = ".scc";
29pub const DB_FILE: &str = "scc.db";
30pub const CONFIG_FILE: &str = "config.yaml";
31pub const CHECKPOINT_FILE: &str = "checkpoint.json";
32
33#[derive(Debug, thiserror::Error)]
34pub enum CliError {
35    #[error("store: {0}")]
36    Store(#[from] scc_store::StoreError),
37    #[error("index: {0}")]
38    Index(#[from] scc_indexer::IndexError),
39    #[error("graph: {0}")]
40    Graph(#[from] scc_graph::GraphError),
41    #[error("io: {0}")]
42    Io(#[from] std::io::Error),
43    #[error("config: {0}")]
44    Config(#[from] scc_indexer::config::ConfigError),
45    #[error("json: {0}")]
46    Json(#[from] serde_json::Error),
47    #[error("{0}")]
48    Other(String),
49}
50
51pub type Result<T> = std::result::Result<T, CliError>;
52
53/// SCC state directory: the repo's `.scc/` by default; `SCC_STATE_DIR`
54/// relocates writable state (database, checkpoint) so the repository itself
55/// can be mounted read-only (docs/DEPLOYMENT_AND_INFRA.md §3: read-only repo
56/// + writable SCC data volume).
57// trace:exempt reason=thin-delegate-engine-owns-behavior
58pub fn state_dir(root: &Path) -> PathBuf {
59    match std::env::var("SCC_STATE_DIR") {
60        Ok(dir) if !dir.is_empty() => PathBuf::from(dir),
61        _ => scc_dir(root),
62    }
63}
64
65// trace:exempt reason=thin-delegate-engine-owns-behavior
66pub fn scc_dir(root: &Path) -> PathBuf {
67    root.join(SCC_DIR)
68}
69
70// trace:exempt reason=thin-delegate-engine-owns-behavior
71pub fn db_path(root: &Path) -> PathBuf {
72    state_dir(root).join(DB_FILE)
73}
74
75/// Config stays in the repo (read-only is fine): it is repository intent,
76/// not SCC state.
77// trace:exempt reason=thin-delegate-engine-owns-behavior
78pub fn config_path(root: &Path) -> PathBuf {
79    scc_dir(root).join(CONFIG_FILE)
80}
81
82// trace:exempt reason=thin-delegate-engine-owns-behavior
83pub fn checkpoint_path(root: &Path) -> PathBuf {
84    state_dir(root).join(CHECKPOINT_FILE)
85}
86
87/// Locate the repository root: walk up from cwd looking for `.git` or an
88/// existing `.scc` dir; otherwise use cwd.
89// trace:exempt reason=thin-delegate-engine-owns-behavior
90pub fn find_root(start: &Path) -> PathBuf {
91    let mut dir = Some(start.to_path_buf());
92    while let Some(d) = dir {
93        if d.join(".git").exists() || d.join(SCC_DIR).exists() {
94            return d;
95        }
96        dir = d.parent().map(|p| p.to_path_buf());
97    }
98    start.to_path_buf()
99}
100
101// trace:exempt reason=thin-delegate-engine-owns-behavior
102pub fn load_config(root: &Path) -> Result<Config> {
103    let p = config_path(root);
104    if p.exists() {
105        Ok(Config::load(&p)?)
106    } else {
107        Ok(Config::default())
108    }
109}
110
111// trace:exempt reason=thin-delegate-engine-owns-behavior
112pub fn open_store(root: &Path) -> Result<Store> {
113    let dir = state_dir(root);
114    std::fs::create_dir_all(&dir)?;
115    Ok(Store::open(&db_path(root), root)?)
116}
117
118/// Ensure `.scc/` is gitignored so the index cache never pollutes the
119/// repo's own git status or gets committed — except committable project
120/// files (`intent.yaml`, `plugins.toml`, `plugins.lock`). Delegates to the
121/// engine; the preserved-file list lives there.
122// trace:v1 id=impl.crates-scc-cli-src-lib.ensure-scc-ignored work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
123pub fn ensure_scc_ignored(root: &Path) {
124    scc_engine::workspace::ensure_scc_ignored(root)
125}
126
127/// True when an indexing failure is store corruption surfacing anywhere in
128/// the error chain (open, mid-index read, recompile) — not just at open.
129/// Corruption can hide behind a valid header and detonate on first touch of
130/// a bad page, so the write path retries from quarantine on any of these.
131// trace:exempt reason=internal-detail
132pub fn is_store_corruption(e: &CliError) -> bool {
133    match e {
134        CliError::Store(s) => Store::is_corruption(s),
135        CliError::Index(scc_indexer::IndexError::Store(s)) => Store::is_corruption(s),
136        CliError::Graph(scc_graph::GraphError::Store(s)) => Store::is_corruption(s),
137        _ => false,
138    }
139}
140
141/// Run an index write; on store corruption anywhere in the attempt,
142/// quarantine the database and retry exactly once from scratch.
143// trace:v1 id=impl.crates-scc-cli-src-lib.resilient-index work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
144pub fn resilient_index<T>(
145    root: &Path,
146    mut attempt: impl FnMut() -> Result<T>,
147) -> Result<T> {
148    match attempt() {
149        Ok(v) => Ok(v),
150        Err(e) if is_store_corruption(&e) => {
151            let q = Store::quarantine_db(&db_path(root))?;
152            report_quarantine(&Some(q));
153            attempt()
154        }
155        Err(e) => Err(e),
156    }
157}
158
159// trace:v1 id=impl.crates-scc-cli-src-lib.open-store-recovering work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
160pub fn open_store_recovering(root: &Path) -> Result<(Store, Option<std::path::PathBuf>)> {
161    let dir = state_dir(root);
162    std::fs::create_dir_all(&dir)?;
163    Ok(Store::open_recovering(&db_path(root), root)?)
164}
165
166// trace:exempt reason=internal-detail
167pub fn report_quarantine(quarantined: &Option<std::path::PathBuf>) {
168    if let Some(q) = quarantined {
169        eprintln!(
170            "warning: existing index was corrupt (malformed database); quarantined to {} and rebuilding from scratch.",
171            q.display()
172        );
173    }
174}
175
176// trace:exempt reason=thin-delegate-engine-owns-behavior
177pub fn recompile(store: &Store) -> Result<scc_graph::RecompileReport> {
178    Ok(scc_graph::recompile(store)?)
179}
180
181/// Compute repository-relative paths whose indexed snapshot no longer matches
182/// the working tree: modified, deleted, AND added files, from ONE
183/// authoritative scan diffed against the indexed inventory — a newly
184/// created relevant file must make the model non-current, and indexed
185/// files are never re-read when the scan already hashed them (one
186/// read+hash per file per freshness check, not two). Same scan the
187/// indexer uses, so both sides share one notion of "repository file"
188/// (git-ignored and configured-ignored paths excluded).
189///
190/// Scaling note: the authoritative scan still reads every candidate file
191/// (correctness first — no mtime cache exists yet, so content hashing is
192/// the only proof of sameness). The scan walk itself is the periodic
193/// reconciliation; a watcher dirty-set + metadata fast path stays
194/// deferred until a daemon owns it.
195// trace:v1 id=impl.crates-scc-cli-src-lib.stale-paths work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
196pub fn stale_paths(store: &Store) -> Result<Vec<String>> {
197    scc_engine::workspace::stale_paths(store).map_err(|e| CliError::Other(e.to_string()))
198}
199
200// trace:exempt reason=existing
201pub struct Compiler<'a> {
202    pub store: &'a Store,
203    pub graph: RealityGraph,
204    pub settings: scc_context::ContextSettings,
205    pub stale: Vec<String>,
206}
207
208/// Build a ready compiler with freshness state.
209// trace:v1 id=impl.crates-scc-cli-src-lib.compiler work=WORK-SCC-001 satisfies=REQ-SCC-API
210pub fn compiler<'a>(
211    store: &'a Store,
212    config: &Config,
213    stale: Vec<String>,
214) -> Result<Compiler<'a>> {
215    let graph = RealityGraph::load(store)?;
216    // Same plugin-keyed salt as scc-engine open_engine: the CLI process and
217    // in-process test compilers must derive identical cache keys.
218    let plugin_salt = scc_engine::workspace::cache_key_fragment(
219        &scc_engine::plugins::active(&store.root, config),
220    );
221    let settings = scc_context::ContextSettings {
222        startup_tokens: config.context.startup_tokens,
223        task_tokens: config.context.task_tokens,
224        atlas_tokens: config.context.atlas_tokens,
225        detail_tokens: config.context.detail_tokens,
226        include_low_confidence_inference: config.context.include_low_confidence_inference,
227        rank_salt: format!(
228            "{}:{}:{}:{}",
229            config.inference.enabled,
230            config.inference.embedding_model,
231            config.inference.rerank_model,
232            plugin_salt,
233        ),
234        pack_allocator: scc_context::PackAllocator::AdaptivePriority,
235    };
236    Ok(Compiler {
237        store,
238        graph,
239        settings,
240        stale,
241    })
242}
243
244// trace:exempt reason=thin-delegate-engine-owns-behavior
245impl Compiler<'_> {
246    /// Construct a ContextCompiler borrowing this compiler's graph.
247    // trace:exempt reason=thin-delegate-engine-owns-behavior
248    pub fn ctx(&self) -> ContextCompiler<'_> {
249        ContextCompiler::new(
250            self.store,
251            &self.graph,
252            self.settings.clone(),
253            self.stale.clone(),
254        )
255    }
256}
257
258// trace:exempt reason=internal-detail
259pub fn index_and_recompile(root: &Path, config: &Config) -> Result<scc_indexer::IndexReport> {
260    ensure_scc_ignored(root);
261    resilient_index(root, || {
262        let (store, quarantined) = open_store_recovering(root)?;
263        report_quarantine(&quarantined);
264    let indexer = scc_indexer::Indexer::new(store, config.clone());
265    let report = indexer.index()?;
266    let store = open_store(root)?;
267    // Wave 4 §24 lazy semantic enrichment: when auto_resolve is on, run the
268    // language-aware backends (pyright/tsserver) before the derived layer
269    // compiles, so flows/atlas see RESOLVED edges.
270    if config.index.auto_resolve {
271        let _ = scc_indexer::resolver::resolve_repository(
272            &store,
273            root,
274            scc_indexer::resolver::MAX_CALL_SITES,
275        );
276    }
277    // No-change fast path (profiler receipt 2026-09-21: a no-change `scc
278    // index` still ran the full derived recompile + revision hash over
279    // 26k rows and bumped the Derived epoch, invalidating warm context
280    // caches). Derived facts are pure of source facts: zero changed /
281    // removed files with a matching extractor means the derived layer is
282    // already current — skip the recompile AND the revision record so
283    // epoch-keyed caches stay warm. Any source, extractor, or resolver
284    // change takes the full path.
285    let unchanged = report.changed == 0 && report.removed == 0 && !config.index.auto_resolve;
286    let extractor_current = store
287        .revisions()
288        .map(|rs| {
289            rs.into_iter().last().map(|h| {
290                h.extractor_version
291                    == format!(
292                        "store:{};core:{}",
293                        scc_store::SCHEMA_VERSION,
294                        scc_core::SCHEMA_VERSION
295                    )
296            })
297            .unwrap_or(false)
298        })
299        .unwrap_or(false);
300    if !(unchanged && extractor_current) {
301        recompile(&store)?;
302    }
303    // Revision AFTER recompile (never inside the indexer): history must
304    // include derived facts (components, boundaries, flows). Recording
305    // before recompile leaves history one recompile behind — V2 content
306    // dedup exposed this ordering bug. Always recorded: the dedup inside
307    // returns the head without appending when nothing changed, preserving
308    // the epoch/ledger invalidation contract the task cache depends on.
309    // (The no-change fast path above skips only the recompile, whose
310    // writes would be byte-identical rows.)
311        let _ = store.record_current_revision_with_config(
312            &scc_indexer::semantic_config_hash(config),
313        )?;
314        // trace:inherit impl.history-retention-knob reason=enforces-retention-at-record-site
315        let _ = store.prune_revisions(config.history.max_revisions);
316        Ok(report)
317    })
318}
319
320/// Run semantic resolution on demand (`--resolve`), then recompile the
321/// derived layer so graphs/flows/atlas reflect the promoted edges.
322// trace:exempt reason=thin-delegate-engine-owns-behavior
323pub fn resolve_and_recompile(root: &Path) -> Result<scc_indexer::resolver::ResolveReport> {
324    let store = open_store(root)?;
325    let report = scc_indexer::resolver::resolve_repository(
326        &store,
327        root,
328        scc_indexer::resolver::MAX_CALL_SITES,
329    )
330    .map_err(CliError::Other)?;
331    recompile(&store)?;
332    Ok(report)
333}
334
335// ---------------------------------------------------------------------------
336// export (docs/DATA_STRATEGY.md §11)
337// ---------------------------------------------------------------------------
338
339// trace:exempt reason=thin-delegate-engine-owns-behavior
340pub fn export_ir(store: &Store) -> Result<scc_core::SystemIr> {
341    let repository = store.repository();
342    let snapshot = store
343        .latest_snapshot()?
344        .unwrap_or(scc_core::Snapshot {
345            revision: "not-indexed".into(),
346            branch: None,
347            indexed_at: scc_core::now_rfc3339(),
348        });
349    let mut ir = scc_core::SystemIr::empty(repository, snapshot);
350    // entities: everything except the derived component copies (they are
351    // already stored as entities by replace_components — dedupe)
352    let mut seen = std::collections::HashSet::new();
353    for e in store.all_entities()? {
354        if seen.insert(e.id.clone()) {
355            ir.entities.push(e);
356        }
357    }
358    ir.relationships = store.all_relationships()?;
359    ir.flows = store.flows()?;
360    ir.invariants = store.invariants()?;
361    ir.evidence = store.all_evidence()?;
362    Ok(ir)
363}
364
365/// JSONL export: one JSON object per line (repository, snapshot, then
366/// entities/relationships/flows/invariants/evidence records).
367// trace:exempt reason=thin-delegate-engine-owns-behavior
368pub fn export_jsonl(ir: &scc_core::SystemIr) -> Result<Vec<String>> {
369    let mut out = Vec::new();
370    out.push(serde_json::to_string(&serde_json::json!({
371        "type": "repository", "repository": ir.repository
372    }))?);
373    out.push(serde_json::to_string(&serde_json::json!({
374        "type": "snapshot", "snapshot": ir.snapshot, "schema_version": ir.schema_version
375    }))?);
376    for e in &ir.entities {
377        out.push(serde_json::to_string(&serde_json::json!({"type": "entity", "entity": e}))?);
378    }
379    for r in &ir.relationships {
380        out.push(serde_json::to_string(&serde_json::json!({"type": "relationship", "relationship": r}))?);
381    }
382    for f in &ir.flows {
383        out.push(serde_json::to_string(&serde_json::json!({"type": "flow", "flow": f}))?);
384    }
385    for i in &ir.invariants {
386        out.push(serde_json::to_string(&serde_json::json!({"type": "invariant", "invariant": i}))?);
387    }
388    for e in &ir.evidence {
389        out.push(serde_json::to_string(&serde_json::json!({"type": "evidence", "evidence": e}))?);
390    }
391    Ok(out)
392}
393
394/// Narsil-CCG-compatible layered export (docs §44): L0 manifest, L1
395/// architecture, L2 symbols.
396// trace:exempt reason=thin-delegate-engine-owns-behavior
397pub fn export_ccg(ir: &scc_core::SystemIr) -> Result<serde_json::Value> {
398    let l1: Vec<serde_json::Value> = ir
399        .entities
400        .iter()
401        .filter(|e| {
402            e.kind == kinds::COMPONENT
403                || e.kind == kinds::SERVICE
404                || e.kind == kinds::DATA_STORE
405                || e.kind == kinds::DEPLOYMENT_UNIT
406                || e.kind == kinds::EXTERNAL_API
407        })
408        .map(|e| {
409            serde_json::json!({
410                "id": e.id,
411                "name": e.name,
412                "kind": e.kind,
413                "attributes": e.attributes,
414            })
415        })
416        .collect();
417    let l2: Vec<serde_json::Value> = ir
418        .entities
419        .iter()
420        .filter(|e| e.kind == kinds::SYMBOL)
421        .map(|e| {
422            serde_json::json!({
423                "id": e.id,
424                "name": e.name,
425                "kind": e.attributes.get("kind").cloned().unwrap_or(serde_json::json!("symbol")),
426                "file": e.attributes.get("file").cloned().unwrap_or_default(),
427            })
428        })
429        .collect();
430    Ok(serde_json::json!({
431        "schema": "ccg",
432        "producer": "scc",
433        "repository": ir.repository,
434        "snapshot": ir.snapshot,
435        "layers": {
436            "L0": {
437                "manifest": {
438                    "repository": ir.repository.name,
439                    "revision": ir.snapshot.revision,
440                    "entity_count": ir.entities.len(),
441                    "relationship_count": ir.relationships.len(),
442                }
443            },
444            "L1": { "architecture": l1 },
445            "L2": { "symbols": l2 },
446        }
447    }))
448}
449
450// trace:exempt reason=thin-delegate-engine-owns-behavior
451pub fn flow_kind_str(k: &scc_core::FlowKind) -> &'static str {
452    match k {
453        scc_core::FlowKind::Architecture => "architecture",
454        scc_core::FlowKind::Workflow => "workflow",
455        scc_core::FlowKind::Sequence => "sequence",
456        scc_core::FlowKind::Dataflow => "dataflow",
457        scc_core::FlowKind::Lifecycle => "lifecycle",
458    }
459}
460
461pub use scc_core::kinds;
462
463/// Repo-relative path of a file under root, or None if it escapes.
464// trace:exempt reason=thin-delegate-engine-owns-behavior
465pub fn relative_of(root: &Path, abs: &Path) -> Option<String> {
466    let root_c = root.canonicalize().ok()?;
467    let abs_c = abs.canonicalize().ok()?;
468    let rel = abs_c.strip_prefix(&root_c).ok()?;
469    if rel.as_os_str().is_empty() {
470        return None;
471    }
472    let s = rel.to_string_lossy().replace('\\', "/");
473    if s.starts_with(".scc/") {
474        return None;
475    }
476    Some(s)
477}