Skip to main content

scc_engine/
index.rs

1//! Engine index operations: full index, path refresh, resolution.
2//!
3//! Moved verbatim from `scc-cli` lib.rs / commands.rs: the revision-after-
4//! recompile ordering contract and the no-change fast path move WITH the
5//! code (parity tests enforce byte-identical reports).
6
7use std::path::Path;
8
9// trace:exempt reason=internal-detail
10pub fn full(root: &Path, config: &scc_indexer::Config) -> crate::Result<scc_indexer::IndexReport> {
11    crate::workspace::ensure_scc_ignored(root);
12    crate::workspace::resilient_index(root, || {
13        let (store, quarantined) = crate::workspace::open_store_recovering(root)?;
14        crate::workspace::report_quarantine(&quarantined);
15        let indexer = scc_indexer::Indexer::new(store, config.clone());
16        let report = indexer.index()?;
17        let store = crate::workspace::open_store(root)?;
18        if config.index.auto_resolve {
19            let _ = scc_indexer::resolver::resolve_repository(
20                &store,
21                root,
22                scc_indexer::resolver::MAX_CALL_SITES,
23            );
24        }
25        let unchanged = report.changed == 0 && report.removed == 0 && !config.index.auto_resolve;
26        let extractor_current = store
27            .revisions()
28            .map(|rs| {
29                rs.into_iter().last().map(|h| {
30                    h.extractor_version
31                        == format!(
32                            "store:{};core:{}",
33                            scc_store::SCHEMA_VERSION,
34                            scc_core::SCHEMA_VERSION
35                        )
36                })
37                .unwrap_or(false)
38            })
39            .unwrap_or(false);
40        if !(unchanged && extractor_current) {
41            recompile_with_signals(root, config, &store)?;
42        }
43        let _ = store.record_current_revision_with_config(
44            &scc_indexer::semantic_config_hash(config),
45        )?;
46        let _ = store.prune_revisions(config.history.max_revisions);
47        Ok(report)
48    })
49}
50
51// trace:exempt reason=internal-detail
52pub fn refresh_paths(root: &Path, config: &scc_indexer::Config, paths: &[String]) -> crate::Result<scc_indexer::IndexReport> {
53    let (store, quarantined) = crate::workspace::open_store_recovering(root)?;
54    crate::workspace::report_quarantine(&quarantined);
55    let indexer = scc_indexer::Indexer::new(crate::workspace::open_store(root)?, config.clone());
56    let report = indexer.refresh_paths(paths)?;
57    drop(indexer);
58    // C1a true no-op fast path: hashes identical AND no deletions means
59    // the store (source facts) and therefore every derived fact is
60    // unchanged — recompiling would rebuild identical tables and record
61    // an identical revision. Skip both; the report already carries
62    // mutated=false so callers can observe the fast path.
63    if !report.mutated {
64        return Ok(report);
65    }
66    // C1b: derive the affected closure from the true changed set. The
67    // pipeline still runs whole-repo until per-stage merge writes land;
68    // the closure bound below is the observable contract the merge work
69    // must exploit (tight closure + full pipeline = the remaining gap).
70    let signals = component_signals(root, config);
71    let fresh = crate::workspace::open_store(root)?;
72    let (_scoped, closure) = scc_graph::recompile_scoped_with_owners(
73        &fresh,
74        &report.affected_files,
75        &report.affected_components,
76        signals,
77    )?;
78    eprintln!(
79        "[scc] refresh closure: {} file(s) -> {} component(s), {} flow(s){}",
80        report.affected_files.len(),
81        closure.components.len(),
82        closure.flows.len(),
83        if closure.complete { " (UNBOUNDED: full pipeline required)" } else { "" },
84    );
85    recompile_with_signals(root, config, &store)?;
86    let _ = store.record_current_revision_with_config(
87        &scc_indexer::semantic_config_hash(config),
88    )?;
89    let _ = store.prune_revisions(config.history.max_revisions);
90    Ok(report)
91}
92
93// trace:exempt reason=internal-detail
94pub fn recompile(store: &scc_store::Store) -> crate::Result<scc_graph::RecompileReport> {
95    Ok(scc_graph::recompile(store)?)
96}
97
98/// Recompile with plugin component signals (§31): collects
99/// `components.signals` from active plugins, then runs the pipeline with
100/// them. Signal failures degrade to empty (the collector never errors).
101// trace:exempt reason=internal-detail
102pub fn recompile_with_signals(
103    root: &std::path::Path,
104    config: &scc_indexer::Config,
105    store: &scc_store::Store,
106) -> crate::Result<scc_graph::RecompileReport> {
107    let signals = component_signals(root, config);
108    Ok(scc_graph::CompilationPipeline::new(store)
109        .component_signals(signals)
110        .run()?)
111}
112
113/// Collect component signals (§31 ComponentSignalProvider) from plugins
114/// answering the `components.signals` operation with
115/// `{"signals": [{name, dirs, provider?}]}`. Unknown/dead plugins, bad
116/// shapes, and empty names/dirs degrade to abstention (never fail the
117/// recompile — a contributor that cannot name a component abstains).
118// trace:exempt reason=internal-detail
119pub fn component_signals(
120    root: &std::path::Path,
121    config: &scc_indexer::Config,
122) -> Vec<scc_graph::components::ComponentSignal> {
123    let mut ap = crate::plugins::active(root, config);
124    let mut out = Vec::new();
125    for plug in ap.plugins.clone() {
126        let pid = plug.manifest.id.clone();
127        let provides = plug.manifest.operations.iter().any(|o| o == "components.signals")
128            || plug.manifest.extensions.iter().any(|e| e.extension_type == "component-signal");
129        if !provides {
130            continue;
131        }
132        match scc_plugin_host::call(&plug, "components.signals", serde_json::json!({}), None) {
133            Ok(v) => {
134                if let Some(arr) = v.get("signals").and_then(|x| x.as_array()) {
135                    for e in arr {
136                        let name = e.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string();
137                        let dirs: Vec<String> = e.get("dirs").and_then(|x| x.as_array()).map(|a| {
138                            a.iter().filter_map(|d| d.as_str().map(str::to_string)).collect()
139                        }).unwrap_or_default();
140                        if name.is_empty() || dirs.is_empty() {
141                            continue;
142                        }
143                        out.push(scc_graph::components::ComponentSignal {
144                            name,
145                            dirs,
146                            provider: e.get("provider").and_then(|x| x.as_str()).unwrap_or(&pid).to_string(),
147                        });
148                    }
149                }
150            }
151            Err(e) => {
152                ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
153                    plugin: pid, operation: "components.signals".into(),
154                    error: e.to_string(), action: "skipped".into(),
155                });
156            }
157        }
158    }
159    out
160}
161
162// trace:exempt reason=internal-detail
163pub fn resolve_and_recompile(root: &Path) -> crate::Result<scc_indexer::resolver::ResolveReport> {
164    let store = crate::workspace::open_store(root)?;
165    let report = scc_indexer::resolver::resolve_repository(
166        &store,
167        root,
168        scc_indexer::resolver::MAX_CALL_SITES,
169    )
170    .map_err(|e| crate::EngineError::Other(e.to_string()))?;
171    let config = crate::workspace::load_config(root)?;
172    recompile_with_signals(root, &config, &store)?;
173    Ok(report)
174}