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 `.scc/intent.yaml`,
120/// which is committable repository intent, not cache. A bare `.scc/`
121/// pattern would make git (and our own gitignore-respecting walker) prune
122/// the whole directory including intent, silently dropping declared
123/// components and flows. Idempotent, never touches other lines.
124// trace:v1 id=impl.crates-scc-cli-src-lib.ensure-scc-ignored work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
125pub fn ensure_scc_ignored(root: &Path) {
126    // A bare `.scc/` line excludes the directory itself, which git does
127    // not let negations re-enter — migrate it to the pair so intent.yaml
128    // stays committable while the cache stays out.
129    const WANT: [&str; 2] = [".scc/*", "!.scc/intent.yaml"];
130    let gi = root.join(".gitignore");
131    let content = std::fs::read_to_string(&gi).unwrap_or_default();
132    let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
133    let mut changed = false;
134    lines.retain(|l| {
135        let bare = l.trim() == ".scc/" || l.trim() == ".scc";
136        if bare {
137            changed = true;
138        }
139        !bare
140    });
141    for line in WANT {
142        if !lines.iter().any(|l| l.trim() == line) {
143            lines.push(line.to_string());
144            changed = true;
145        }
146    }
147    if changed {
148        let mut out = lines.join("\n");
149        out.push('\n');
150        let _ = std::fs::write(&gi, out);
151    }
152}
153
154/// True when an indexing failure is store corruption surfacing anywhere in
155/// the error chain (open, mid-index read, recompile) — not just at open.
156/// Corruption can hide behind a valid header and detonate on first touch of
157/// a bad page, so the write path retries from quarantine on any of these.
158// trace:exempt reason=internal-detail
159pub fn is_store_corruption(e: &CliError) -> bool {
160    match e {
161        CliError::Store(s) => Store::is_corruption(s),
162        CliError::Index(scc_indexer::IndexError::Store(s)) => Store::is_corruption(s),
163        CliError::Graph(scc_graph::GraphError::Store(s)) => Store::is_corruption(s),
164        _ => false,
165    }
166}
167
168/// Run an index write; on store corruption anywhere in the attempt,
169/// quarantine the database and retry exactly once from scratch.
170// trace:v1 id=impl.crates-scc-cli-src-lib.resilient-index work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
171pub fn resilient_index<T>(
172    root: &Path,
173    mut attempt: impl FnMut() -> Result<T>,
174) -> Result<T> {
175    match attempt() {
176        Ok(v) => Ok(v),
177        Err(e) if is_store_corruption(&e) => {
178            let q = Store::quarantine_db(&db_path(root))?;
179            report_quarantine(&Some(q));
180            attempt()
181        }
182        Err(e) => Err(e),
183    }
184}
185
186// trace:v1 id=impl.crates-scc-cli-src-lib.open-store-recovering work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
187pub fn open_store_recovering(root: &Path) -> Result<(Store, Option<std::path::PathBuf>)> {
188    let dir = state_dir(root);
189    std::fs::create_dir_all(&dir)?;
190    Ok(Store::open_recovering(&db_path(root), root)?)
191}
192
193// trace:exempt reason=internal-detail
194pub fn report_quarantine(quarantined: &Option<std::path::PathBuf>) {
195    if let Some(q) = quarantined {
196        eprintln!(
197            "warning: existing index was corrupt (malformed database); quarantined to {} and rebuilding from scratch.",
198            q.display()
199        );
200    }
201}
202
203// trace:exempt reason=thin-delegate-engine-owns-behavior
204pub fn recompile(store: &Store) -> Result<scc_graph::RecompileReport> {
205    Ok(scc_graph::recompile(store)?)
206}
207
208/// Compute repository-relative paths whose indexed snapshot no longer matches
209/// the working tree: modified, deleted, AND added files, from ONE
210/// authoritative scan diffed against the indexed inventory — a newly
211/// created relevant file must make the model non-current, and indexed
212/// files are never re-read when the scan already hashed them (one
213/// read+hash per file per freshness check, not two). Same scan the
214/// indexer uses, so both sides share one notion of "repository file"
215/// (git-ignored and configured-ignored paths excluded).
216///
217/// Scaling note: the authoritative scan still reads every candidate file
218/// (correctness first — no mtime cache exists yet, so content hashing is
219/// the only proof of sameness). The scan walk itself is the periodic
220/// reconciliation; a watcher dirty-set + metadata fast path stays
221/// deferred until a daemon owns it.
222// trace:v1 id=impl.crates-scc-cli-src-lib.stale-paths work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
223pub fn stale_paths(store: &Store) -> Result<Vec<String>> {
224    scc_engine::workspace::stale_paths(store).map_err(|e| CliError::Other(e.to_string()))
225}
226
227// trace:exempt reason=existing
228pub struct Compiler<'a> {
229    pub store: &'a Store,
230    pub graph: RealityGraph,
231    pub settings: scc_context::ContextSettings,
232    pub stale: Vec<String>,
233}
234
235/// Build a ready compiler with freshness state.
236// trace:v1 id=impl.crates-scc-cli-src-lib.compiler work=WORK-SCC-001 satisfies=REQ-SCC-API
237pub fn compiler<'a>(
238    store: &'a Store,
239    config: &Config,
240    stale: Vec<String>,
241) -> Result<Compiler<'a>> {
242    let graph = RealityGraph::load(store)?;
243    // Same plugin-keyed salt as scc-engine open_engine: the CLI process and
244    // in-process test compilers must derive identical cache keys.
245    let plugin_salt = scc_engine::workspace::cache_key_fragment(
246        &scc_engine::plugins::active(&store.root, config),
247    );
248    let settings = scc_context::ContextSettings {
249        startup_tokens: config.context.startup_tokens,
250        task_tokens: config.context.task_tokens,
251        atlas_tokens: config.context.atlas_tokens,
252        detail_tokens: config.context.detail_tokens,
253        include_low_confidence_inference: config.context.include_low_confidence_inference,
254        rank_salt: format!(
255            "{}:{}:{}:{}",
256            config.inference.enabled,
257            config.inference.embedding_model,
258            config.inference.rerank_model,
259            plugin_salt,
260        ),
261        pack_allocator: scc_context::PackAllocator::AdaptivePriority,
262    };
263    Ok(Compiler {
264        store,
265        graph,
266        settings,
267        stale,
268    })
269}
270
271// trace:exempt reason=thin-delegate-engine-owns-behavior
272impl Compiler<'_> {
273    /// Construct a ContextCompiler borrowing this compiler's graph.
274    // trace:exempt reason=thin-delegate-engine-owns-behavior
275    pub fn ctx(&self) -> ContextCompiler<'_> {
276        ContextCompiler::new(
277            self.store,
278            &self.graph,
279            self.settings.clone(),
280            self.stale.clone(),
281        )
282    }
283}
284
285// trace:exempt reason=internal-detail
286pub fn index_and_recompile(root: &Path, config: &Config) -> Result<scc_indexer::IndexReport> {
287    ensure_scc_ignored(root);
288    resilient_index(root, || {
289        let (store, quarantined) = open_store_recovering(root)?;
290        report_quarantine(&quarantined);
291    let indexer = scc_indexer::Indexer::new(store, config.clone());
292    let report = indexer.index()?;
293    let store = open_store(root)?;
294    // Wave 4 §24 lazy semantic enrichment: when auto_resolve is on, run the
295    // language-aware backends (pyright/tsserver) before the derived layer
296    // compiles, so flows/atlas see RESOLVED edges.
297    if config.index.auto_resolve {
298        let _ = scc_indexer::resolver::resolve_repository(
299            &store,
300            root,
301            scc_indexer::resolver::MAX_CALL_SITES,
302        );
303    }
304    // No-change fast path (profiler receipt 2026-09-21: a no-change `scc
305    // index` still ran the full derived recompile + revision hash over
306    // 26k rows and bumped the Derived epoch, invalidating warm context
307    // caches). Derived facts are pure of source facts: zero changed /
308    // removed files with a matching extractor means the derived layer is
309    // already current — skip the recompile AND the revision record so
310    // epoch-keyed caches stay warm. Any source, extractor, or resolver
311    // change takes the full path.
312    let unchanged = report.changed == 0 && report.removed == 0 && !config.index.auto_resolve;
313    let extractor_current = store
314        .revisions()
315        .map(|rs| {
316            rs.into_iter().last().map(|h| {
317                h.extractor_version
318                    == format!(
319                        "store:{};core:{}",
320                        scc_store::SCHEMA_VERSION,
321                        scc_core::SCHEMA_VERSION
322                    )
323            })
324            .unwrap_or(false)
325        })
326        .unwrap_or(false);
327    if !(unchanged && extractor_current) {
328        recompile(&store)?;
329    }
330    // Revision AFTER recompile (never inside the indexer): history must
331    // include derived facts (components, boundaries, flows). Recording
332    // before recompile leaves history one recompile behind — V2 content
333    // dedup exposed this ordering bug. Always recorded: the dedup inside
334    // returns the head without appending when nothing changed, preserving
335    // the epoch/ledger invalidation contract the task cache depends on.
336    // (The no-change fast path above skips only the recompile, whose
337    // writes would be byte-identical rows.)
338        let _ = store.record_current_revision_with_config(
339            &scc_indexer::semantic_config_hash(config),
340        )?;
341        Ok(report)
342    })
343}
344
345/// Run semantic resolution on demand (`--resolve`), then recompile the
346/// derived layer so graphs/flows/atlas reflect the promoted edges.
347// trace:exempt reason=thin-delegate-engine-owns-behavior
348pub fn resolve_and_recompile(root: &Path) -> Result<scc_indexer::resolver::ResolveReport> {
349    let store = open_store(root)?;
350    let report = scc_indexer::resolver::resolve_repository(
351        &store,
352        root,
353        scc_indexer::resolver::MAX_CALL_SITES,
354    )
355    .map_err(CliError::Other)?;
356    recompile(&store)?;
357    Ok(report)
358}
359
360// ---------------------------------------------------------------------------
361// export (docs/DATA_STRATEGY.md §11)
362// ---------------------------------------------------------------------------
363
364// trace:exempt reason=thin-delegate-engine-owns-behavior
365pub fn export_ir(store: &Store) -> Result<scc_core::SystemIr> {
366    let repository = store.repository();
367    let snapshot = store
368        .latest_snapshot()?
369        .unwrap_or(scc_core::Snapshot {
370            revision: "not-indexed".into(),
371            branch: None,
372            indexed_at: scc_core::now_rfc3339(),
373        });
374    let mut ir = scc_core::SystemIr::empty(repository, snapshot);
375    // entities: everything except the derived component copies (they are
376    // already stored as entities by replace_components — dedupe)
377    let mut seen = std::collections::HashSet::new();
378    for e in store.all_entities()? {
379        if seen.insert(e.id.clone()) {
380            ir.entities.push(e);
381        }
382    }
383    ir.relationships = store.all_relationships()?;
384    ir.flows = store.flows()?;
385    ir.invariants = store.invariants()?;
386    ir.evidence = store.all_evidence()?;
387    Ok(ir)
388}
389
390/// JSONL export: one JSON object per line (repository, snapshot, then
391/// entities/relationships/flows/invariants/evidence records).
392// trace:exempt reason=thin-delegate-engine-owns-behavior
393pub fn export_jsonl(ir: &scc_core::SystemIr) -> Result<Vec<String>> {
394    let mut out = Vec::new();
395    out.push(serde_json::to_string(&serde_json::json!({
396        "type": "repository", "repository": ir.repository
397    }))?);
398    out.push(serde_json::to_string(&serde_json::json!({
399        "type": "snapshot", "snapshot": ir.snapshot, "schema_version": ir.schema_version
400    }))?);
401    for e in &ir.entities {
402        out.push(serde_json::to_string(&serde_json::json!({"type": "entity", "entity": e}))?);
403    }
404    for r in &ir.relationships {
405        out.push(serde_json::to_string(&serde_json::json!({"type": "relationship", "relationship": r}))?);
406    }
407    for f in &ir.flows {
408        out.push(serde_json::to_string(&serde_json::json!({"type": "flow", "flow": f}))?);
409    }
410    for i in &ir.invariants {
411        out.push(serde_json::to_string(&serde_json::json!({"type": "invariant", "invariant": i}))?);
412    }
413    for e in &ir.evidence {
414        out.push(serde_json::to_string(&serde_json::json!({"type": "evidence", "evidence": e}))?);
415    }
416    Ok(out)
417}
418
419/// Narsil-CCG-compatible layered export (docs §44): L0 manifest, L1
420/// architecture, L2 symbols.
421// trace:exempt reason=thin-delegate-engine-owns-behavior
422pub fn export_ccg(ir: &scc_core::SystemIr) -> Result<serde_json::Value> {
423    let l1: Vec<serde_json::Value> = ir
424        .entities
425        .iter()
426        .filter(|e| {
427            e.kind == kinds::COMPONENT
428                || e.kind == kinds::SERVICE
429                || e.kind == kinds::DATA_STORE
430                || e.kind == kinds::DEPLOYMENT_UNIT
431                || e.kind == kinds::EXTERNAL_API
432        })
433        .map(|e| {
434            serde_json::json!({
435                "id": e.id,
436                "name": e.name,
437                "kind": e.kind,
438                "attributes": e.attributes,
439            })
440        })
441        .collect();
442    let l2: Vec<serde_json::Value> = ir
443        .entities
444        .iter()
445        .filter(|e| e.kind == kinds::SYMBOL)
446        .map(|e| {
447            serde_json::json!({
448                "id": e.id,
449                "name": e.name,
450                "kind": e.attributes.get("kind").cloned().unwrap_or(serde_json::json!("symbol")),
451                "file": e.attributes.get("file").cloned().unwrap_or_default(),
452            })
453        })
454        .collect();
455    Ok(serde_json::json!({
456        "schema": "ccg",
457        "producer": "scc",
458        "repository": ir.repository,
459        "snapshot": ir.snapshot,
460        "layers": {
461            "L0": {
462                "manifest": {
463                    "repository": ir.repository.name,
464                    "revision": ir.snapshot.revision,
465                    "entity_count": ir.entities.len(),
466                    "relationship_count": ir.relationships.len(),
467                }
468            },
469            "L1": { "architecture": l1 },
470            "L2": { "symbols": l2 },
471        }
472    }))
473}
474
475// trace:exempt reason=thin-delegate-engine-owns-behavior
476pub fn flow_kind_str(k: &scc_core::FlowKind) -> &'static str {
477    match k {
478        scc_core::FlowKind::Architecture => "architecture",
479        scc_core::FlowKind::Workflow => "workflow",
480        scc_core::FlowKind::Sequence => "sequence",
481        scc_core::FlowKind::Dataflow => "dataflow",
482        scc_core::FlowKind::Lifecycle => "lifecycle",
483    }
484}
485
486pub use scc_core::kinds;
487
488/// Repo-relative path of a file under root, or None if it escapes.
489// trace:exempt reason=thin-delegate-engine-owns-behavior
490pub fn relative_of(root: &Path, abs: &Path) -> Option<String> {
491    let root_c = root.canonicalize().ok()?;
492    let abs_c = abs.canonicalize().ok()?;
493    let rel = abs_c.strip_prefix(&root_c).ok()?;
494    if rel.as_os_str().is_empty() {
495        return None;
496    }
497    let s = rel.to_string_lossy().replace('\\', "/");
498    if s.starts_with(".scc/") {
499        return None;
500    }
501    Some(s)
502}