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 fn startup_sections(
118 root: &std::path::Path,
119 config: &scc_indexer::Config,
120) -> (String, Vec<String>) {
121 let ap = active(root, config);
122 let mut out = String::new();
123 let mut notes: Vec<String> = Vec::new();
124 let specs: Vec<(String, String, String)> = ap
125 .plugins
126 .iter()
127 .flat_map(|p| {
128 p.manifest.extensions.iter().filter(|e| e.extension_type == "startup-section").map(|e| {
129 (p.manifest.id.clone(), e.id.clone(), p.manifest.failure_policy.clone())
130 })
131 })
132 .collect();
133 for (pid, ext_id, policy) in specs {
134 let plug = match ap.plugins.iter().find(|p| p.manifest.id == pid).cloned() {
135 Some(p) => p,
136 None => continue,
137 };
138 let input = serde_json::json!({"section": ext_id});
139 match scc_plugin_host::call(&plug, "startup.section", input, None) {
140 Ok(v) => {
141 let text = v.get("section").and_then(|s| s.as_str()).unwrap_or("");
142 if !text.trim().is_empty() {
143 out.push_str(&format!("\n# PLUGIN STARTUP SECTION {ext_id} (from {pid} — plugin content, not verified facts)\n"));
144 out.push_str(text.trim());
145 out.push('\n');
146 }
147 }
148 Err(e) => {
149 if policy == "required" {
150 out.push_str(&format!("\nPLUGIN STARTUP SECTION {ext_id} from {pid} FAILED: {e}\n"));
151 } else {
152 notes.push(format!("startup section {ext_id} from {pid} skipped ({e})"));
153 }
154 }
155 }
156 }
157 (out, notes)
158}
159
160pub fn verify_diagnostics(
168 root: &std::path::Path,
169 config: &scc_indexer::Config,
170) -> (String, Vec<String>) {
171 let ap = active(root, config);
172 let mut out = String::new();
173 let mut notes: Vec<String> = Vec::new();
174 let specs: Vec<(String, String, String)> = ap
175 .plugins
176 .iter()
177 .flat_map(|p| {
178 p.manifest.extensions.iter().filter(|e| e.extension_type == "verify-diagnostic").map(|e| {
179 (p.manifest.id.clone(), e.id.clone(), p.manifest.failure_policy.clone())
180 })
181 })
182 .collect();
183 for (pid, ext_id, policy) in specs {
184 let plug = match ap.plugins.iter().find(|p| p.manifest.id == pid).cloned() {
185 Some(p) => p,
186 None => continue,
187 };
188 let input = serde_json::json!({"diagnostic": ext_id});
189 match scc_plugin_host::call(&plug, "verify.diagnostic", input, None) {
190 Ok(v) => {
191 let text = v.get("diagnostic").and_then(|s| s.as_str()).unwrap_or("");
192 if !text.trim().is_empty() {
193 out.push_str(&format!("\n# PLUGIN VERIFY DIAGNOSTIC {ext_id} (from {pid} — plugin content, not verified facts)\n"));
194 out.push_str(text.trim());
195 out.push('\n');
196 }
197 }
198 Err(e) => {
199 if policy == "required" {
200 out.push_str(&format!("\n# PLUGIN VERIFY DIAGNOSTIC {ext_id} from {pid} FAILED: {e}\n"));
201 } else {
202 notes.push(format!("verify diagnostic {ext_id} from {pid} skipped ({e})"));
203 }
204 }
205 }
206 }
207 (out, notes)
208}
209
210pub fn viewer_panels(
218 root: &std::path::Path,
219 config: &scc_indexer::Config,
220) -> (Vec<serde_json::Value>, Vec<String>) {
221 let ap = active(root, config);
222 let mut panels = Vec::new();
223 let mut notes = Vec::new();
224 let specs: Vec<(String, String, String)> = ap
225 .plugins
226 .iter()
227 .flat_map(|pl| {
228 pl.manifest.extensions.iter()
229 .filter(|e| e.extension_type == "viewer-panel")
230 .map(|e| (pl.manifest.id.clone(), e.id.clone(), pl.manifest.failure_policy.clone()))
231 })
232 .collect();
233 for (pid, ext_id, policy) in specs {
234 let plug = match ap.plugins.iter().find(|pl| pl.manifest.id == pid).cloned() {
235 Some(pl) => pl,
236 None => continue,
237 };
238 match scc_plugin_host::call(&plug, "viewer.panel", serde_json::json!({"panel": ext_id}), None) {
239 Ok(v) => {
240 let title = v.get("title").and_then(|x| x.as_str()).unwrap_or(&ext_id).to_string();
241 let html = v.get("html").and_then(|x| x.as_str()).unwrap_or("").to_string();
242 if html.to_lowercase().contains("<script") {
245 notes.push(format!("viewer panel {ext_id} from {pid} skipped (inline <script> not allowed)"));
246 } else if !html.trim().is_empty() {
247 panels.push(serde_json::json!({
248 "id": ext_id, "plugin": pid, "title": title, "html": html,
249 }));
250 }
251 }
252 Err(e) => {
253 if policy == "required" {
254 panels.push(serde_json::json!({
255 "id": ext_id, "plugin": pid, "title": ext_id,
256 "html": format!("panel failed: {e}"),
257 }));
258 } else {
259 notes.push(format!("viewer panel {ext_id} from {pid} skipped ({e})"));
260 }
261 }
262 }
263 }
264 (panels, notes)
265}
266
267pub fn quota_overrides(
276 ap: &ActivePlugins,
277 req: &scc_api::SelectionRequest,
278) -> (Vec<(String, f64)>, Vec<scc_plugin_host::PluginDiagnostic>) {
279 let mut out = Vec::new();
280 let mut notes = Vec::new();
281 let specs: Vec<(String, String, String)> = ap
282 .plugins
283 .iter()
284 .flat_map(|pl| {
285 pl.manifest.extensions.iter()
286 .filter(|e| e.extension_type == "quota-policy")
287 .map(|e| (pl.manifest.id.clone(), e.id.clone(), pl.manifest.failure_policy.clone()))
288 })
289 .collect();
290 for (pid, ext_id, policy) in specs {
291 let plug = match ap.plugins.iter().find(|pl| pl.manifest.id == pid).cloned() {
292 Some(pl) => pl,
293 None => continue,
294 };
295 let ranked: Vec<serde_json::Value> = req.ranked.iter().map(|e| {
296 serde_json::json!({"id": e.id, "kind": e.kind, "value": e.value, "token_cost": e.token_cost})
297 }).collect();
298 let quotas: Vec<serde_json::Value> = req.quotas.clone().unwrap_or_default().into_iter().map(|q| {
299 serde_json::json!({"kind": q.kind, "fraction": q.fraction})
300 }).collect();
301 let input = serde_json::json!({"policy": ext_id, "ranked": ranked, "quotas": quotas});
302 match scc_plugin_host::call(&plug, "selection.quotas", input, None) {
303 Ok(v) => {
304 if let Some(arr) = v.get("quotas").and_then(|x| x.as_array()) {
305 for q in arr {
306 let kind = q.get("kind").and_then(|x| x.as_str()).unwrap_or("");
307 let frac = q.get("fraction").and_then(|x| x.as_f64()).unwrap_or(-1.0);
308 if !kind.is_empty() && (0.0..=1.0).contains(&frac) {
309 out.push((kind.to_string(), frac));
310 }
311 }
312 }
313 }
314 Err(e) => {
315 if policy == "required" {
316 notes.push(scc_plugin_host::PluginDiagnostic {
317 plugin: pid, operation: "selection.quotas".into(),
318 error: format!("quota policy {ext_id} failed: {e}"), action: "failed".into(),
319 });
320 }
321 }
322 }
323 }
324 (out, notes)
325}
326
327pub fn budget_selection(
336 ap: &ActivePlugins,
337 req: &scc_api::SelectionRequest,
338 items: &[scc_core::ContextItem],
339 budget: usize,
340) -> crate::Result<Option<Vec<String>>> {
341 let specs: Vec<(String, String, String)> = ap
342 .plugins
343 .iter()
344 .flat_map(|pl| {
345 pl.manifest.extensions.iter()
346 .filter(|e| e.extension_type == "budget-optimizer")
347 .map(|e| (pl.manifest.id.clone(), e.id.clone(), pl.manifest.failure_policy.clone()))
348 })
349 .collect();
350 if specs.is_empty() {
351 return Ok(None);
352 }
353 if specs.len() > 1 {
354 let who: Vec<String> = specs.iter().map(|(pid, eid, _)| format!("{eid} from {pid}")).collect();
355 return Err(crate::EngineError::Other(format!(
356 "multiple budget-optimizer extensions compete ({}) — exclusive policy slot needs explicit configuration (spec §32)",
357 who.join(", ")
358 )));
359 }
360 let (pid, ext_id, policy) = &specs[0];
361 let plug = ap.plugins.iter().find(|pl| &pl.manifest.id == pid).cloned()
362 .ok_or_else(|| crate::EngineError::Other(format!("budget optimizer plugin {pid} vanished")))?;
363 let ranked: Vec<serde_json::Value> = req.ranked.iter().map(|e| {
364 serde_json::json!({"id": e.id, "kind": e.kind, "value": e.value, "token_cost": e.token_cost})
365 }).collect();
366 let input = serde_json::json!({"optimizer": ext_id, "ranked": ranked, "budget": budget});
367 match scc_plugin_host::call(&plug, "selection.optimize", input, None) {
368 Ok(v) => {
369 let known: std::collections::BTreeSet<&str> =
370 items.iter().map(|i| i.id.as_str()).collect();
371 let mut sel = Vec::new();
372 if let Some(arr) = v.get("selected").and_then(|x| x.as_array()) {
373 for x in arr {
374 let id = x.as_str().unwrap_or("");
375 if !known.contains(id) {
376 return Err(crate::EngineError::Other(format!(
377 "budget optimizer {ext_id} from {pid} selected unknown id '{id}'"
378 )));
379 }
380 sel.push(id.to_string());
381 }
382 }
383 Ok(Some(sel))
384 }
385 Err(e) => {
386 if policy == "required" {
387 Err(crate::EngineError::Other(format!(
388 "budget optimizer {ext_id} from {pid} failed: {e}"
389 )))
390 } else {
391 Ok(None)
392 }
393 }
394 }
395}
396
397pub fn diversity_selection(
406 ap: &ActivePlugins,
407 req: &scc_api::SelectionRequest,
408) -> crate::Result<Option<Vec<String>>> {
409 let specs: Vec<(String, String, String)> = ap
410 .plugins
411 .iter()
412 .flat_map(|pl| {
413 pl.manifest.extensions.iter()
414 .filter(|e| e.extension_type == "diversity-policy")
415 .map(|e| (pl.manifest.id.clone(), e.id.clone(), pl.manifest.failure_policy.clone()))
416 })
417 .collect();
418 if specs.is_empty() {
419 return Ok(None);
420 }
421 if specs.len() > 1 {
422 let who: Vec<String> = specs.iter().map(|(pid, eid, _)| format!("{eid} from {pid}")).collect();
423 return Err(crate::EngineError::Other(format!(
424 "multiple diversity-policy extensions compete ({}) — exclusive policy slot needs explicit configuration (spec §32)",
425 who.join(", ")
426 )));
427 }
428 let (pid, ext_id, policy) = &specs[0];
429 let plug = ap.plugins.iter().find(|pl| &pl.manifest.id == pid).cloned()
430 .ok_or_else(|| crate::EngineError::Other(format!("diversity plugin {pid} vanished")))?;
431 let ranked: Vec<serde_json::Value> = req.ranked.iter().map(|e| {
432 serde_json::json!({"id": e.id, "value": e.value})
433 }).collect();
434 let input = serde_json::json!({
435 "policy": ext_id, "ranked": ranked,
436 "lambda": req.lambda.unwrap_or(0.5),
437 });
438 match scc_plugin_host::call(&plug, "selection.diversify", input, None) {
439 Ok(v) => {
440 let known: std::collections::BTreeSet<&str> =
441 req.ranked.iter().map(|e| e.id.as_str()).collect();
442 let mut sel = Vec::new();
443 if let Some(arr) = v.get("selected").and_then(|x| x.as_array()) {
444 for x in arr {
445 let id = x.as_str().unwrap_or("");
446 if !known.contains(id) {
447 return Err(crate::EngineError::Other(format!(
448 "diversity policy {ext_id} from {pid} selected unknown id '{id}'"
449 )));
450 }
451 sel.push(id.to_string());
452 }
453 }
454 Ok(Some(sel))
455 }
456 Err(e) => {
457 if policy == "required" {
458 Err(crate::EngineError::Other(format!(
459 "diversity policy {ext_id} from {pid} failed: {e}"
460 )))
461 } else {
462 Ok(None)
463 }
464 }
465 }
466}
467
468pub fn promote_sidecar(
480 store: &scc_store::Store,
481 plugin_id: &str,
482 assertions: &serde_json::Value,
483) -> crate::Result<serde_json::Value> {
484 let arr = assertions.as_array().cloned().unwrap_or_default();
485 let mut rels = Vec::new();
486 for (i, a) in arr.iter().enumerate() {
487 let sub = a.get("subject").and_then(|v| v.as_str()).unwrap_or("");
488 let pred = a.get("predicate").and_then(|v| v.as_str()).unwrap_or("");
489 let obj = a.get("object").and_then(|v| v.as_str()).unwrap_or("");
490 if sub.is_empty() || pred.is_empty() || obj.is_empty() {
491 return Err(crate::EngineError::Other(format!(
492 "promotion assertion #{i}: subject/predicate/object required"
493 )));
494 }
495 if !scc_core::predicates::ALL.contains(&pred) {
496 return Err(crate::EngineError::Other(format!(
497 "promotion assertion #{i}: predicate '{pred}' is not core ontology (custom predicates stay 'plugin:<id>/...' via plugins.contribute)"
498 )));
499 }
500 let conf = a.get("confidence").and_then(|v| v.as_f64()).unwrap_or(-1.0);
501 if !(0.0..=1.0).contains(&conf) {
502 return Err(crate::EngineError::Other(format!(
503 "promotion assertion #{i}: confidence {conf} outside [0,1]"
504 )));
505 }
506 let exact = a.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);
507 let prov = if exact { "RESOLVED" } else { "INFERRED" };
508 let ev: Vec<serde_json::Value> = a.get("evidence").and_then(|v| v.as_array()).cloned().unwrap_or_default();
509 rels.push(serde_json::json!({
510 "id": format!("plugin:{plugin_id}/promoted/{i}"),
511 "subject": sub, "predicate": pred, "object": obj,
512 "provenance": prov, "confidence": conf, "evidence": ev,
513 }));
514 }
515 let batch = serde_json::json!({
516 "entities": [], "relationships": rels,
517 "evidence": [], "diagnostics": [],
518 });
519 commit_contribution(store, plugin_id, &batch)
520}
521
522pub struct ExtensionOrder {
527 pub extension_type: String,
528 pub id: String,
529 pub priority: i32,
530 pub after: Vec<String>,
531 pub before: Vec<String>,
532}
533
534impl ExtensionOrder {
536 pub fn key(&self) -> String { format!("{}:{}", self.extension_type, self.id) }
538}
539
540pub fn order_extensions(extensions: &[ExtensionOrder]) -> crate::Result<Vec<usize>> {
542 use std::collections::{BTreeMap, BTreeSet};
543 let n = extensions.len();
544 let key_of = |i: usize| -> String { extensions[i].key() };
546 let mut index: BTreeMap<String, usize> = BTreeMap::new();
547 for i in 0..n {
548 let k = key_of(i);
549 if index.insert(k.clone(), i).is_some() {
550 return Err(crate::EngineError::Other(format!("duplicate extension registration '{k}'")));
551 }
552 }
553 let mut preds: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
555 let mut succs: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
556 for i in 0..n {
557 for a in &extensions[i].after {
558 let j = *index.get(a).ok_or_else(|| {
559 crate::EngineError::Other(format!("extension '{}' orders after unknown '{}'", key_of(i), a))
560 })?;
561 if j != i {
562 preds[i].insert(j);
563 succs[j].insert(i);
564 }
565 }
566 for b in &extensions[i].before {
567 let j = *index.get(b).ok_or_else(|| {
568 crate::EngineError::Other(format!("extension '{}' orders before unknown '{}'", key_of(i), b))
569 })?;
570 if j != i {
571 succs[i].insert(j);
572 preds[j].insert(i);
573 }
574 }
575 }
576 let mut ready: Vec<usize> = (0..n).filter(|&i| preds[i].is_empty()).collect();
578 let sort_key = |i: &usize| (extensions[*i].priority, extensions[*i].id.clone(), *i);
579 ready.sort_by_key(sort_key);
580 let mut out = Vec::with_capacity(n);
581 while let Some(i) = ready.first().cloned() {
582 ready.remove(0);
583 out.push(i);
584 let mut newly: Vec<usize> = Vec::new();
585 for &j in &succs[i] {
586 preds[j].remove(&i);
587 if preds[j].is_empty() {
588 newly.push(j);
589 }
590 }
591 ready.extend(newly);
592 ready.sort_by_key(sort_key);
593 }
594 if out.len() != n {
595 let stuck: Vec<String> = (0..n).filter(|i| !out.contains(i)).map(key_of).collect();
596 return Err(crate::EngineError::Other(format!("extension ordering cycle: {}", stuck.join(", "))));
597 }
598 Ok(out)
599}
600
601pub(crate) fn collect_extensions(ap: &ActivePlugins) -> Vec<ExtensionOrder> {
604 let mut out = Vec::new();
605 for p in &ap.plugins {
606 for e in &p.manifest.extensions {
607 out.push(ExtensionOrder { extension_type: e.extension_type.clone(), id: e.id.clone(), priority: e.priority, after: e.after.clone(), before: e.before.clone() });
608 }
609 }
610 out
611}
612
613pub fn validate_contribution(
621 store: &scc_store::Store,
622 plugin_id: &str,
623 batch: &serde_json::Value,
624) -> crate::Result<serde_json::Value> {
625 if let Some(obj) = batch.as_object() {
630 let known = ["entities", "relationships", "evidence", "diagnostics"];
631 let unknown: Vec<&str> = obj.keys().filter(|k| !known.contains(&k.as_str())).map(|k| k.as_str()).collect();
632 if !unknown.is_empty() {
633 return Err(crate::EngineError::Other(format!(
634 "contribution has unsupported top-level keys [{}] (supported: entities, relationships, evidence, diagnostics); flows/invariants/contracts derive from entities at graph-compile time",
635 unknown.join(", ")
636 )));
637 }
638 }
639 let entities = batch.get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default();
640 let relationships = batch.get("relationships").and_then(|v| v.as_array()).cloned().unwrap_or_default();
641 let evidence = batch.get("evidence").and_then(|v| v.as_array()).cloned().unwrap_or_default();
642 let mut ids: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
643 for e in store.all_entities()? {
644 ids.insert(e.id);
645 }
646 for (i, e) in entities.iter().enumerate() {
647 let id = e.get("id").and_then(|v| v.as_str()).unwrap_or("");
648 let kind = e.get("kind").and_then(|v| v.as_str()).unwrap_or("");
649 if id.is_empty() {
650 return Err(crate::EngineError::Other(format!("contribution entity #{i}: missing id")));
651 }
652 if kind.is_empty() {
653 return Err(crate::EngineError::Other(format!("contribution entity '{id}': missing kind")));
654 }
655 if kind.contains('/') && !kind.starts_with("plugin:") {
656 return Err(crate::EngineError::Other(format!(
657 "contribution entity '{id}': custom kind '{kind}' must be namespaced 'plugin:<id>/...'"
658 )));
659 }
660 ids.insert(id.to_string());
661 }
662 for (i, r) in relationships.iter().enumerate() {
663 let (sub, pred, obj) = (
664 r.get("subject").and_then(|v| v.as_str()).unwrap_or(""),
665 r.get("predicate").and_then(|v| v.as_str()).unwrap_or(""),
666 r.get("object").and_then(|v| v.as_str()).unwrap_or(""),
667 );
668 if sub.is_empty() || pred.is_empty() || obj.is_empty() {
669 return Err(crate::EngineError::Other(format!("contribution relationship #{i}: subject/predicate/object required")));
670 }
671 if pred.contains('/') && !pred.starts_with("plugin:") {
672 return Err(crate::EngineError::Other(format!(
673 "contribution relationship #{i}: custom predicate '{pred}' must be namespaced 'plugin:<id>/...'"
674 )));
675 }
676 for end in [sub, obj] {
677 if !ids.contains(end) {
678 return Err(crate::EngineError::Other(format!(
679 "contribution relationship #{i}: dangling endpoint '{end}'"
680 )));
681 }
682 }
683 }
684 for (i, e) in evidence.iter().enumerate() {
685 if e.get("id").and_then(|v| v.as_str()).unwrap_or("").is_empty() {
686 return Err(crate::EngineError::Other(format!("contribution evidence #{i}: missing id")));
687 }
688 }
689 let stamp = |mut v: serde_json::Value| -> serde_json::Value {
691 if let Some(obj) = v.as_object_mut() {
692 obj.insert("_origin".into(), serde_json::json!({
693 "kind": "plugin", "plugin_id": plugin_id,
694 }));
695 }
696 v
697 };
698 let diagnostics = batch.get("diagnostics").and_then(|v| v.as_array()).cloned().unwrap_or_default();
699 Ok(serde_json::json!({
700 "entities": entities.into_iter().map(stamp).collect::<Vec<_>>(),
701 "relationships": relationships.into_iter().map(stamp).collect::<Vec<_>>(),
702 "evidence": evidence.into_iter().map(stamp).collect::<Vec<_>>(),
703 "diagnostics": diagnostics,
704 }))
705}
706
707pub fn commit_contribution(
711 store: &scc_store::Store,
712 plugin_id: &str,
713 batch: &serde_json::Value,
714) -> crate::Result<serde_json::Value> {
715 let checked = validate_contribution(store, plugin_id, batch)?;
719 let entities: Vec<scc_core::Entity> = checked
720 .get("entities").and_then(|v| v.as_array()).cloned().unwrap_or_default()
721 .into_iter().map(serde_json::from_value)
722 .collect::<std::result::Result<Vec<_>, _>>()
723 .map_err(|e| crate::EngineError::Other(format!("contribution entity decode: {e}")))?;
724 let rels: Vec<scc_core::Relationship> = checked
725 .get("relationships").and_then(|v| v.as_array()).cloned().unwrap_or_default()
726 .into_iter().map(serde_json::from_value)
727 .collect::<std::result::Result<Vec<_>, _>>()
728 .map_err(|e| crate::EngineError::Other(format!("contribution relationship decode: {e}")))?;
729 let mut evs: Vec<scc_core::Evidence> = checked
730 .get("evidence").and_then(|v| v.as_array()).cloned().unwrap_or_default()
731 .into_iter().map(serde_json::from_value)
732 .collect::<std::result::Result<Vec<_>, _>>()
733 .map_err(|e| crate::EngineError::Other(format!("contribution evidence decode: {e}")))?;
734 for ev in evs.iter_mut() {
735 ev.extractor = Some(format!("plugin:{plugin_id}"));
737 }
738 store.batch_begin().map_err(crate::EngineError::Store)?;
739 let mut counts = (0usize, 0usize, 0usize);
740 for e in &entities {
741 if let Err(err) = store.insert_entity(e, &[format!("plugin:{plugin_id}")]) {
742 store.batch_abort();
743 return Err(crate::EngineError::Store(err));
744 }
745 counts.0 += 1;
746 }
747 for r in &rels {
748 if let Err(err) = store.insert_relationship(r, &format!("plugin:{plugin_id}")) {
749 store.batch_abort();
750 return Err(crate::EngineError::Store(err));
751 }
752 counts.1 += 1;
753 }
754 for ev in &evs {
755 if let Err(err) = store.insert_evidence(ev) {
756 store.batch_abort();
757 return Err(crate::EngineError::Store(err));
758 }
759 counts.2 += 1;
760 }
761 store.batch_end().map_err(crate::EngineError::Store)?;
762 let n_diag = checked.get("diagnostics").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
763 Ok(serde_json::json!({"entities": counts.0, "relationships": counts.1, "evidence": counts.2, "diagnostics": n_diag}))
764}
765
766pub fn context_sections(
775 root: &std::path::Path,
776 config: &scc_indexer::Config,
777 goal: &str,
778 files: &[String],
779 symbols: &[String],
780) -> String {
781 let mut ap = active(root, config);
782 let mut out = String::new();
784 let specs: Vec<(String, String, String)> = ap
785 .plugins
786 .iter()
787 .flat_map(|p| {
788 p.manifest.extensions.iter().filter(|e| e.extension_type == "context-section").map(|e| {
789 (p.manifest.id.clone(), e.id.clone(), p.manifest.failure_policy.clone())
790 })
791 })
792 .collect();
793 for (pid, ext_id, policy) in specs {
794 let plug = match ap.plugins.iter().find(|p| p.manifest.id == pid).cloned() {
795 Some(p) => p,
796 None => continue,
797 };
798 let input = serde_json::json!({"goal": goal, "files": files, "symbols": symbols, "section": ext_id});
799 match scc_plugin_host::call(&plug, "context.section", input, None) {
800 Ok(v) => {
801 if let Some(section) = v.get("section").and_then(|s| s.as_str()) {
802 if !section.trim().is_empty() {
803 out.push_str(&format!("\n# PLUGIN SECTION {ext_id} (from {pid} — plugin content, not verified facts)\n"));
804 out.push_str(section.trim());
805 out.push('\n');
806 }
807 }
808 }
809 Err(e) => {
810 if policy == "required" {
811 out.push_str(&format!("\n# PLUGIN SECTION {ext_id} FAILED (from {pid}): {e}\n"));
812 }
813 ap.diagnostics.push(scc_plugin_host::PluginDiagnostic {
814 plugin: pid, operation: "context.section".into(), error: e.to_string(),
815 action: if policy == "required" { "failed".into() } else { "skipped".into() },
816 });
817 }
818 }
819 }
820 let _ = ap;
823 out
824}