1use std::path::Path;
12
13pub struct ActivePlugins {
15 pub plugins: Vec<scc_plugin_host::LoadedPlugin>,
16 pub diagnostics: Vec<scc_plugin_host::PluginDiagnostic>,
17}
18
19pub fn active(root: &Path, config: &scc_indexer::Config) -> ActivePlugins {
21 let mut ap = ActivePlugins { plugins: Vec::new(), diagnostics: Vec::new() };
22 let mut plugins = scc_plugin_host::discover(root);
23 if !config.plugins.enabled.is_empty() {
25 plugins.retain(|p| config.plugins.enabled.contains(&p.manifest.id));
26 }
27 for p in &mut plugins {
29 if let Some(c) = config.plugins.config.get(&p.manifest.id) {
30 p.config = c.clone();
31 }
32 }
33 let mut grants: std::collections::BTreeMap<String, Vec<scc_plugin_api::Permission>> =
37 std::collections::BTreeMap::new();
38 for (k, v) in &config.plugins.grants {
39 let mut perms = Vec::new();
40 for s in v {
41 match scc_plugin_api::Permission::parse(s) {
42 Ok(p) => perms.push(p),
43 Err(e) => ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
44 plugin: k.clone(), operation: "grants".into(),
45 error: e, action: "skipped".into(),
46 }),
47 }
48 }
49 grants.insert(k.clone(), perms);
50 }
51 scc_plugin_host::apply_grants(&mut plugins, &grants);
52 ap.plugins = plugins;
53 ap
54}
55
56pub fn lock_entries(ap: &ActivePlugins) -> Vec<serde_json::Value> {
58 ap.plugins.iter().map(scc_plugin_host::lock_entry).collect()
59}
60
61pub fn cache_key_fragment(ap: &ActivePlugins) -> String {
63 let mut h = blake3::Hasher::new();
65 for e in lock_entries(ap) {
66 h.update(e.to_string().as_bytes());
67 }
68 format!("plugins:{}", &h.finalize().to_hex()[..16])
69}
70
71pub fn call_operation(ap: &mut ActivePlugins, operation: &str, input: serde_json::Value) -> crate::Result<serde_json::Value> {
76 let plugin = scc_plugin_host::provider_for(&ap.plugins, operation).map_err(|e| crate::EngineError::Other(e.to_string()))?;
77 let id = plugin.manifest.id.clone();
78 let policy = plugin.manifest.failure_policy.clone();
79 match scc_plugin_host::call(plugin, operation, input, None) {
80 Ok(output) => Ok(with_provenance(&id, &plugin.manifest.version, operation, output)),
81 Err(e) => {
82 let action = if policy == "required" { "failed" } else { "skipped" };
83 ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
84 plugin: id.clone(), operation: operation.into(), error: e.to_string(), action: action.into(),
85 });
86 if policy == "required" {
87 Err(crate::EngineError::Other(format!("plugin {id} {operation}: {e}")))
88 } else {
89 Ok(serde_json::json!({"skipped": true, "plugin": id, "error": e.to_string()}))
90 }
91 }
92 }
93}
94
95fn with_provenance(plugin_id: &str, version: &str, operation: &str, mut output: serde_json::Value) -> serde_json::Value {
97 if let Some(obj) = output.as_object_mut() {
99 obj.insert("_origin".into(), serde_json::json!({
100 "kind": "plugin", "plugin_id": plugin_id,
101 "plugin_version": version, "extension": operation,
102 }));
103 }
104 output
105}
106
107pub struct ExtensionOrder {
112 pub extension_type: String,
113 pub id: String,
114 pub priority: i32,
115 pub after: Vec<String>,
116 pub before: Vec<String>,
117}
118
119impl ExtensionOrder {
121 pub fn key(&self) -> String { format!("{}:{}", self.extension_type, self.id) }
123}
124
125pub fn order_extensions(extensions: &[ExtensionOrder]) -> crate::Result<Vec<usize>> {
127 use std::collections::{BTreeMap, BTreeSet};
128 let n = extensions.len();
129 let key_of = |i: usize| -> String { extensions[i].key() };
131 let mut index: BTreeMap<String, usize> = BTreeMap::new();
132 for i in 0..n {
133 let k = key_of(i);
134 if index.insert(k.clone(), i).is_some() {
135 return Err(crate::EngineError::Other(format!("duplicate extension registration '{k}'")));
136 }
137 }
138 let mut preds: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
140 let mut succs: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
141 for i in 0..n {
142 for a in &extensions[i].after {
143 let j = *index.get(a).ok_or_else(|| {
144 crate::EngineError::Other(format!("extension '{}' orders after unknown '{}'", key_of(i), a))
145 })?;
146 if j != i {
147 preds[i].insert(j);
148 succs[j].insert(i);
149 }
150 }
151 for b in &extensions[i].before {
152 let j = *index.get(b).ok_or_else(|| {
153 crate::EngineError::Other(format!("extension '{}' orders before unknown '{}'", key_of(i), b))
154 })?;
155 if j != i {
156 succs[i].insert(j);
157 preds[j].insert(i);
158 }
159 }
160 }
161 let mut ready: Vec<usize> = (0..n).filter(|&i| preds[i].is_empty()).collect();
163 let sort_key = |i: &usize| (extensions[*i].priority, extensions[*i].id.clone(), *i);
164 ready.sort_by_key(sort_key);
165 let mut out = Vec::with_capacity(n);
166 while let Some(i) = ready.first().cloned() {
167 ready.remove(0);
168 out.push(i);
169 let mut newly: Vec<usize> = Vec::new();
170 for &j in &succs[i] {
171 preds[j].remove(&i);
172 if preds[j].is_empty() {
173 newly.push(j);
174 }
175 }
176 ready.extend(newly);
177 ready.sort_by_key(sort_key);
178 }
179 if out.len() != n {
180 let stuck: Vec<String> = (0..n).filter(|i| !out.contains(i)).map(key_of).collect();
181 return Err(crate::EngineError::Other(format!("extension ordering cycle: {}", stuck.join(", "))));
182 }
183 Ok(out)
184}
185
186pub(crate) fn collect_extensions(ap: &ActivePlugins) -> Vec<ExtensionOrder> {
189 let mut out = Vec::new();
190 for p in &ap.plugins {
191 for e in &p.manifest.extensions {
192 out.push(ExtensionOrder { extension_type: e.extension_type.clone(), id: e.id.clone(), priority: e.priority, after: e.after.clone(), before: e.before.clone() });
193 }
194 }
195 out
196}
197
198pub fn validate_contribution(
206 store: &scc_store::Store,
207 plugin_id: &str,
208 batch: &serde_json::Value,
209) -> crate::Result<serde_json::Value> {
210 if let Some(obj) = batch.as_object() {
215 let known = ["entities", "relationships", "evidence", "diagnostics"];
216 let unknown: Vec<&str> = obj.keys().filter(|k| !known.contains(&k.as_str())).map(|k| k.as_str()).collect();
217 if !unknown.is_empty() {
218 return Err(crate::EngineError::Other(format!(
219 "contribution has unsupported top-level keys [{}] (supported: entities, relationships, evidence, diagnostics); flows/invariants/contracts derive from entities at graph-compile time",
220 unknown.join(", ")
221 )));
222 }
223 }
224 let entities = batch.get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default();
225 let relationships = batch.get("relationships").and_then(|v| v.as_array()).cloned().unwrap_or_default();
226 let evidence = batch.get("evidence").and_then(|v| v.as_array()).cloned().unwrap_or_default();
227 let mut ids: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
228 for e in store.all_entities()? {
229 ids.insert(e.id);
230 }
231 for (i, e) in entities.iter().enumerate() {
232 let id = e.get("id").and_then(|v| v.as_str()).unwrap_or("");
233 let kind = e.get("kind").and_then(|v| v.as_str()).unwrap_or("");
234 if id.is_empty() {
235 return Err(crate::EngineError::Other(format!("contribution entity #{i}: missing id")));
236 }
237 if kind.is_empty() {
238 return Err(crate::EngineError::Other(format!("contribution entity '{id}': missing kind")));
239 }
240 if kind.contains('/') && !kind.starts_with("plugin:") {
241 return Err(crate::EngineError::Other(format!(
242 "contribution entity '{id}': custom kind '{kind}' must be namespaced 'plugin:<id>/...'"
243 )));
244 }
245 ids.insert(id.to_string());
246 }
247 for (i, r) in relationships.iter().enumerate() {
248 let (sub, pred, obj) = (
249 r.get("subject").and_then(|v| v.as_str()).unwrap_or(""),
250 r.get("predicate").and_then(|v| v.as_str()).unwrap_or(""),
251 r.get("object").and_then(|v| v.as_str()).unwrap_or(""),
252 );
253 if sub.is_empty() || pred.is_empty() || obj.is_empty() {
254 return Err(crate::EngineError::Other(format!("contribution relationship #{i}: subject/predicate/object required")));
255 }
256 if pred.contains('/') && !pred.starts_with("plugin:") {
257 return Err(crate::EngineError::Other(format!(
258 "contribution relationship #{i}: custom predicate '{pred}' must be namespaced 'plugin:<id>/...'"
259 )));
260 }
261 for end in [sub, obj] {
262 if !ids.contains(end) {
263 return Err(crate::EngineError::Other(format!(
264 "contribution relationship #{i}: dangling endpoint '{end}'"
265 )));
266 }
267 }
268 }
269 for (i, e) in evidence.iter().enumerate() {
270 if e.get("id").and_then(|v| v.as_str()).unwrap_or("").is_empty() {
271 return Err(crate::EngineError::Other(format!("contribution evidence #{i}: missing id")));
272 }
273 }
274 let stamp = |mut v: serde_json::Value| -> serde_json::Value {
276 if let Some(obj) = v.as_object_mut() {
277 obj.insert("_origin".into(), serde_json::json!({
278 "kind": "plugin", "plugin_id": plugin_id,
279 }));
280 }
281 v
282 };
283 let diagnostics = batch.get("diagnostics").and_then(|v| v.as_array()).cloned().unwrap_or_default();
284 Ok(serde_json::json!({
285 "entities": entities.into_iter().map(stamp).collect::<Vec<_>>(),
286 "relationships": relationships.into_iter().map(stamp).collect::<Vec<_>>(),
287 "evidence": evidence.into_iter().map(stamp).collect::<Vec<_>>(),
288 "diagnostics": diagnostics,
289 }))
290}
291
292pub fn commit_contribution(
296 store: &scc_store::Store,
297 plugin_id: &str,
298 batch: &serde_json::Value,
299) -> crate::Result<serde_json::Value> {
300 let checked = validate_contribution(store, plugin_id, batch)?;
304 let entities: Vec<scc_core::Entity> = checked
305 .get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default()
306 .into_iter().map(serde_json::from_value)
307 .collect::<std::result::Result<Vec<_>, _>>()
308 .map_err(|e| crate::EngineError::Other(format!("contribution entity decode: {e}")))?;
309 let rels: Vec<scc_core::Relationship> = checked
310 .get("relationships").and_then(|v| v.as_array()).cloned().unwrap_or_default()
311 .into_iter().map(serde_json::from_value)
312 .collect::<std::result::Result<Vec<_>, _>>()
313 .map_err(|e| crate::EngineError::Other(format!("contribution relationship decode: {e}")))?;
314 let mut evs: Vec<scc_core::Evidence> = checked
315 .get("evidence").and_then(|v| v.as_array()).cloned().unwrap_or_default()
316 .into_iter().map(serde_json::from_value)
317 .collect::<std::result::Result<Vec<_>, _>>()
318 .map_err(|e| crate::EngineError::Other(format!("contribution evidence decode: {e}")))?;
319 for ev in evs.iter_mut() {
320 ev.extractor = Some(format!("plugin:{plugin_id}"));
322 }
323 store.batch_begin().map_err(crate::EngineError::Store)?;
324 let mut counts = (0usize, 0usize, 0usize);
325 for e in &entities {
326 if let Err(err) = store.insert_entity(e, &[format!("plugin:{plugin_id}")]) {
327 store.batch_abort();
328 return Err(crate::EngineError::Store(err));
329 }
330 counts.0 += 1;
331 }
332 for r in &rels {
333 if let Err(err) = store.insert_relationship(r, &format!("plugin:{plugin_id}")) {
334 store.batch_abort();
335 return Err(crate::EngineError::Store(err));
336 }
337 counts.1 += 1;
338 }
339 for ev in &evs {
340 if let Err(err) = store.insert_evidence(ev) {
341 store.batch_abort();
342 return Err(crate::EngineError::Store(err));
343 }
344 counts.2 += 1;
345 }
346 store.batch_end().map_err(crate::EngineError::Store)?;
347 let n_diag = checked.get("diagnostics").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
348 Ok(serde_json::json!({"entities": counts.0, "relationships": counts.1, "evidence": counts.2, "diagnostics": n_diag}))
349}
350
351pub fn context_sections(
360 root: &std::path::Path,
361 config: &scc_indexer::Config,
362 goal: &str,
363 files: &[String],
364 symbols: &[String],
365) -> String {
366 let mut ap = active(root, config);
367 let mut out = String::new();
369 let specs: Vec<(String, String, String)> = ap
370 .plugins
371 .iter()
372 .flat_map(|p| {
373 p.manifest.extensions.iter().filter(|e| e.extension_type == "context-section").map(|e| {
374 (p.manifest.id.clone(), e.id.clone(), p.manifest.failure_policy.clone())
375 })
376 })
377 .collect();
378 for (pid, ext_id, policy) in specs {
379 let plug = match ap.plugins.iter().find(|p| p.manifest.id == pid).cloned() {
380 Some(p) => p,
381 None => continue,
382 };
383 let input = serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "section": ext_id});
384 match scc_plugin_host::call(&plug, "context.section", input, None) {
385 Ok(v) => {
386 if let Some(section) = v.get("section").and_then(|s| s.as_str()) {
387 if !section.trim().is_empty() {
388 out.push_str(&format!("\n# PLUGIN SECTION {ext_id} (from {pid} — plugin content, not verified facts)\n"));
389 out.push_str(section.trim());
390 out.push('\n');
391 }
392 }
393 }
394 Err(e) => {
395 if policy == "required" {
396 out.push_str(&format!("\n# PLUGIN SECTION {ext_id} FAILED (from {pid}): {e}\n"));
397 }
398 ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
399 plugin: pid, operation: "context.section".into(), error: e.to_string(),
400 action: if policy == "required" { "failed".into() } else { "skipped".into() },
401 });
402 }
403 }
404 }
405 let _ = ap;
408 out
409}