scc-engine 0.2.11

SCC engine facade: one orchestration seam for CLI, HTTP, MCP, SDKs, RPC, FFI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
//! Plugin host integration: a Python process plugin end-to-end.
//!
//! Proves spec section 15 (arbitrary languages, no Rust) and section 31
//! (custom operations reachable through invoke with no per-transport code).

use std::io::Write;

// trace:exempt reason=internal-detail
fn write_plugin(dir: &std::path::Path) -> std::path::PathBuf {
    let plugdir = dir.join(".scc").join("plugins").join("acme.echo");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.echo\"\nname = \"Echo\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"acme.echo\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nprint(json.dumps({\"output\": {\"echo\": req[\"input\"].get(\"text\", \"\")}}))\n").unwrap();
    dir.to_path_buf()
}

#[test]
// trace:v1 id=test.scc-engine-plugins.echo-round-trip verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn process_plugin_echo_round_trip() {
    let dir = tempfile::TempDir::new().unwrap();
    let root = write_plugin(dir.path());
    let mut ap = scc_engine::plugins::active(&root, &scc_indexer::Config::default());
    assert_eq!(ap.plugins.len(), 1, "echo plugin discovered");
    let out = scc_engine::plugins::call_operation(&mut ap, "acme.echo", serde_json::json!({"text": "hello"})).unwrap();
    assert_eq!(out.get("echo").and_then(|v| v.as_str()), Some("hello"));
    assert!(out.get("_origin").is_some(), "mandatory provenance {out}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.unknown-op verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn unknown_operation_is_no_provider() {
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    let mut ap = scc_engine::plugins::active(&root, &scc_indexer::Config::default());
    let r = scc_engine::plugins::call_operation(&mut ap, "acme.missing", serde_json::json!({}));
    assert!(r.is_err());
}

#[test]
// trace:v1 id=test.scc-engine-plugins.lock-invalidates verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.cache-key-fragment
fn lock_changes_cache_key() {
    let dir = tempfile::TempDir::new().unwrap();
    let root = write_plugin(dir.path());
    let ap = scc_engine::plugins::active(&root, &scc_indexer::Config::default());
    let k1 = scc_engine::plugins::cache_key_fragment(&ap);
    assert!(k1.starts_with("plugins:"));
    assert_eq!(k1, scc_engine::plugins::cache_key_fragment(&ap));
}

#[test]
// trace:v1 id=test.scc-engine-plugins.config-grants-invalidate verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.cache-key-fragment
fn plugin_config_and_grants_invalidate_cache_key() {
    // Spec 27: per-instance config and effective grants change behavior,
    // so they must change the cache key — otherwise a config-only change
    // serves stale cached packs as fresh.
    let dir = tempfile::TempDir::new().unwrap();
    let root = write_plugin(dir.path());
    let base_cfg = scc_indexer::Config::default();
    let ap0 = scc_engine::plugins::active(&root, &base_cfg);
    let k0 = scc_engine::plugins::cache_key_fragment(&ap0);
    // Config change: same manifest, different risk_weight.
    let mut cfg1 = scc_indexer::Config::default();
    cfg1.plugins.config.insert(
        "acme.echo".into(),
        serde_json::json!({"risk_weight": 0.9}),
    );
    let ap1 = scc_engine::plugins::active(&root, &cfg1);
    let k1 = scc_engine::plugins::cache_key_fragment(&ap1);
    assert_ne!(k0, k1, "config change must invalidate the cache key");
    // lock_entry carries the config for lockfile reproducibility.
    let e1 = scc_plugin_host::lock_entry(&ap1.plugins[0]);
    assert_eq!(e1.get("config"), Some(&serde_json::json!({"risk_weight": 0.9})), "{e1}");
    // Grant narrowing: same manifest, fewer effective grants.
    let mut cfg2 = scc_indexer::Config::default();
    cfg2.plugins.grants.insert("acme.echo".into(), vec!["state.read".into()]);
    let ap2 = scc_engine::plugins::active(&root, &cfg2);
    let k2 = scc_engine::plugins::cache_key_fragment(&ap2);
    assert_ne!(k0, k2, "grant change must invalidate the cache key");
    let e2 = scc_plugin_host::lock_entry(&ap2.plugins[0]);
    assert_eq!(e2.get("grants"), Some(&serde_json::json!(["state.read"])), "{e2}");
    // Determinism: same inputs, same key.
    let ap0b = scc_engine::plugins::active(&root, &base_cfg);
    assert_eq!(k0, scc_engine::plugins::cache_key_fragment(&ap0b));
}

#[test]
// trace:v1 id=test.scc-engine-plugins.extension-wires-feature verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn extension_registration_wires_rank_feature() {
    use std::io::Write;
    // Repo with two symbols; goal favors zeta so alpha starts below.
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def zeta():\n    return 1\ndef alpha():\n    return 2\n").unwrap();
    // Feature plugin registered ONLY via [extensions] (no legacy op name
    // needed for wiring — operations still declares the callable op).
    let plugdir = root.join(".scc").join("plugins").join("acme.feat");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.feat\"\nname = \"Feat\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"ranking.feature\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"rank-feature:acme.feat\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nsym = req[\"input\"].get(\"symbol\", \"\")\nboost = 1.0 if \"alpha\" in sym else 0.0\nprint(json.dumps({\"output\": {\"score\": boost, \"weight\": 100.0, \"reason\": \"acme-boost\"}}))\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let out = scc_engine::invoke(
        &root,
        "ranking.symbols",
        serde_json::json!({"goal": "zeta", "limit": 10, "explain": true, "include_features": true}),
    )
    .unwrap();
    let items = out["items"].as_array().unwrap();
    assert!(!items.is_empty(), "ranked items {out}");
    // The extension-registered feature moved alpha above zeta.
    let pos = |sub: &str| items.iter().position(|i| i["id"].as_str().unwrap_or("").contains(sub)).unwrap();
    assert!(pos("alpha") < pos("zeta"), "acme-boost moved alpha up: {out}");
    assert!(
        items[pos("alpha")]["plugin_features"].get("acme.feat.feature").is_some(),
        "feature recorded under plugin name: {out}"
    );
    assert!(
        items[pos("alpha")]["reasons"].as_array().unwrap().iter().any(|r| r == "acme-boost"),
        "reason recorded: {out}"
    );
}

#[test]
// trace:v1 id=test.scc-engine-plugins.ordering verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.order-extensions
fn order_extensions_respects_priority_and_edges() {
    use scc_engine::plugins::ExtensionOrder;
    let exts = vec![
        ExtensionOrder { extension_type: "rank-feature".into(), id: "c".into(), priority: 30, after: vec![], before: vec![] },
        ExtensionOrder { extension_type: "rank-feature".into(), id: "a".into(), priority: 10, after: vec![], before: vec![] },
        ExtensionOrder { extension_type: "rank-feature".into(), id: "b".into(), priority: 20, after: vec!["rank-feature:a".into()], before: vec![] },
    ];
    let order = scc_engine::plugins::order_extensions(&exts).unwrap();
    let ids: Vec<&str> = order.iter().map(|&i| exts[i].id.as_str()).collect();
    assert_eq!(ids, vec!["a", "b", "c"], "{ids:?}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.ordering-rejects verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.order-extensions
fn order_extensions_rejects_unknown_and_cycle() {
    use scc_engine::plugins::ExtensionOrder;
    let unknown = vec![
        ExtensionOrder { extension_type: "t".into(), id: "x".into(), priority: 0, after: vec!["t:nope".into()], before: vec![] },
    ];
    assert!(scc_engine::plugins::order_extensions(&unknown).is_err());
    let cycle = vec![
        ExtensionOrder { extension_type: "t".into(), id: "x".into(), priority: 0, after: vec!["t:y".into()], before: vec![] },
        ExtensionOrder { extension_type: "t".into(), id: "y".into(), priority: 0, after: vec!["t:x".into()], before: vec![] },
    ];
    assert!(scc_engine::plugins::order_extensions(&cycle).is_err());
}

#[test]
// trace:v1 id=test.scc-engine-plugins.contribution-pipeline verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.commit-contribution
fn contribution_pipeline_validates_then_commits() {
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let store = scc_store::Store::open(&dir.path().join("scc.db"), &root).unwrap();
    // Dangling endpoint fails BEFORE any write.
    let bad = serde_json::json!({"entities": [], "relationships": [
        {"id": "r1", "subject": "ghost", "predicate": "calls", "object": "ghost2", "provenance": "extracted", "confidence": 1.0}
    ], "evidence": []});
    assert!(scc_engine::plugins::commit_contribution(&store, "acme.t", &bad).is_err());
    assert!(store.search_entities("ghost", 10).unwrap().is_empty());
    // Valid batch commits with provenance.
    let good = serde_json::json!({"entities": [
        {"id": "plugin:acme/boundary", "kind": "plugin:acme/security_boundary", "name": "edge", "attributes": {}, "evidence": []}
    ], "relationships": [], "evidence": []});
    let out = scc_engine::plugins::commit_contribution(&store, "acme.t", &good).unwrap();
    assert_eq!(out.get("entities"), Some(&serde_json::json!(1)));
    let found = store.search_entities("edge", 10).unwrap();
    assert!(found.iter().any(|e| e.id == "plugin:acme/boundary"), "{found:?}");
    // Custom kind without the plugin: namespace is rejected.
    let unscoped = serde_json::json!({"entities": [
        {"id": "x", "kind": "custom/thing", "name": "x", "attributes": {}, "evidence": []}
    ], "relationships": [], "evidence": []});
    assert!(scc_engine::plugins::commit_contribution(&store, "acme.t", &unscoped).is_err());
}

#[test]
// trace:v1 id=test.scc-engine-plugins.contribution-unknown-keys verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.commit-contribution
fn contribution_unknown_keys_fail_loudly() {
    // A plugin sending `flows`/`invariants` must get an error, not a
    // silent drop: those derive from entities at graph-compile time.
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let store = scc_store::Store::open(&dir.path().join("scc.db"), &root).unwrap();
    for key in ["flows", "invariants", "contracts", "typo_key"] {
        let batch = serde_json::json!({"entities": [], "relationships": [], "evidence": []});
        let mut obj = batch.as_object().cloned().unwrap();
        obj.insert(key.into(), serde_json::json!([]));
        let err = scc_engine::plugins::commit_contribution(&store, "acme.t", &serde_json::Value::Object(obj));
        assert!(err.is_err(), "key '{key}' must be rejected, not dropped: {err:?}");
        assert!(err.unwrap_err().to_string().contains(key), "error names the key");
    }
    // diagnostics is accepted and echoed in the result count.
    let with_diag = serde_json::json!({"entities": [], "relationships": [], "evidence": [],
        "diagnostics": [{"level": "warn", "message": "m"}]});
    let out = scc_engine::plugins::commit_contribution(&store, "acme.t", &with_diag).unwrap();
    assert_eq!(out.get("diagnostics"), Some(&serde_json::json!(1)), "{out}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.contribution-atomic verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.commit-contribution
fn contribution_mid_batch_failure_leaves_no_partial_state() {
    // Spec 24: a broken plugin must not leave half a graph. The first
    // entity decodes, the second does not — the whole batch must abort
    // with nothing committed (decode happens before any write).
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let store = scc_store::Store::open(&dir.path().join("scc.db"), &root).unwrap();
    let before = store.stats().unwrap()["entities"];
    let mixed = serde_json::json!({"entities": [
        {"id": "plugin:acme/ok", "kind": "plugin:acme/thing", "name": "ok", "attributes": {}, "evidence": []},
        {"id": "plugin:acme/bad", "kind": "plugin:acme/thing", "name": 42, "attributes": {}, "evidence": []}
    ], "relationships": [], "evidence": []});
    assert!(scc_engine::plugins::commit_contribution(&store, "acme.t", &mixed).is_err());
    assert!(store.search_entities("ok", 10).unwrap().is_empty(), "partial entity must roll back");
    assert_eq!(store.stats().unwrap()["entities"], before, "entity count unchanged");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.features-op verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-ranking.symbols
fn features_op_serves_decomposition() {
    // Live-RPC proof: `ranking.features` returns the per-symbol
    // decomposition with all 8 core keys + plugin map, same hooks as
    // ranking.symbols (zero transport code).
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let out = scc_engine::invoke(&root, "ranking.features",
        serde_json::json!({"goal": "alpha", "limit": 10})).unwrap();
    let feats = out["features"].as_array().unwrap();
    assert!(!feats.is_empty(), "{out}");
    for f in feats {
        for k in ["task_ppr", "global_ppr", "lexical", "semantic",
                  "confidence", "criticality", "change_risk", "novelty"] {
            assert!(f.get(k).and_then(|x| x.as_f64()).is_some(), "missing {k}: {f}");
        }
        assert!(f.get("plugin_features").is_some(), "{f}");
    }
}

#[test]
// trace:v1 id=test.scc-engine-plugins.seeds-op verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn seeds_op_serves_plugin_merge() {
    // Live-RPC proof: `ranking.seeds` merges lexical + provider weights
    // through the real invoke path (same hook wiring as ranking.symbols).
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let base = scc_engine::invoke(&root, "ranking.seeds",
        serde_json::json!({"goal": "alpha"})).unwrap();
    assert!(!base["seeds"].as_array().unwrap().is_empty(), "{base}");
    let first = base["seeds"][0]["id"].as_str().unwrap().to_string();
    let w0 = base["seeds"][0]["weight"].as_f64().unwrap();
    let plugdir = root.join(".scc").join("plugins").join("acme.seed");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.seed\"\nname = \"Seed\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"ranking.seed\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"seed-provider:acme.s\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    std::fs::write(plugdir.join("plugin.py"), format!(
        "import json, sys\nprint(json.dumps({{\"output\": {{\"seeds\": [{{\"kind\": \"symbol\", \"id\": \"{first}\", \"weight\": 5.0}}]}}}}))\n")).unwrap();
    let out = scc_engine::invoke(&root, "ranking.seeds",
        serde_json::json!({"goal": "alpha"})).unwrap();
    let hit = out["seeds"].as_array().unwrap().iter()
        .find(|x| x["id"] == first).unwrap();
    assert!((hit["weight"].as_f64().unwrap() - (w0 + 5.0)).abs() < 1e-9, "{out}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.sidecar-raw verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.promote-sidecar
fn sidecar_round_trip_stays_out_of_canonical() {
    // §124 item 35 (raw half): sidecar facts round-trip namespaced per
    // (plugin, graph) and never leak into the canonical graph. Grant-gated
    // like plugin state; promotion stays an explicit separate step.
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let before = scc_engine::invoke(&root, "export.system_ir",
        serde_json::json!({"format": "system-ir.json"})).unwrap();
    let n_entities = before["entities"].as_array().unwrap().len();
    // No grant: denied. (Manifest below grants state_*; probe first with
    // a grantless plugin id — unknown plugin fails loudly.)
    let denied = scc_engine::invoke(&root, "sidecar.put", serde_json::json!({
        "plugin": "acme.ghost", "graph": "joern-cpg",
        "key": "node/1", "value": {"kind": "METHOD"},
    }));
    assert!(denied.is_err(), "{denied:?}");
    // Granted plugin: put/get/scan round-trip.
    let plugdir = root.join(".scc").join("plugins").join("acme.side");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.side\"\nname = \"Side\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = []\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[permissions]\nstate_read = true\nstate_write = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json\nprint(json.dumps({\"output\": {}}))\n").unwrap();
    let ok = scc_engine::invoke(&root, "sidecar.put", serde_json::json!({
        "plugin": "acme.side", "graph": "joern-cpg",
        "key": "node/1", "value": {"kind": "METHOD", "name": "hello"},
    })).unwrap();
    assert_eq!(ok["ok"], serde_json::json!(true), "{ok}");
    let got = scc_engine::invoke(&root, "sidecar.get", serde_json::json!({
        "plugin": "acme.side", "graph": "joern-cpg", "key": "node/1",
    })).unwrap();
    assert!(got.as_str().unwrap().contains("METHOD"), "{got}");
    let scan = scc_engine::invoke(&root, "sidecar.scan", serde_json::json!({
        "plugin": "acme.side", "graph": "joern-cpg", "prefix": "node/",
    })).unwrap();
    assert_eq!(scan["keys"].as_array().unwrap().len(), 1, "{scan}");
    // Other graphs are isolated namespaces.
    let scan2 = scc_engine::invoke(&root, "sidecar.scan", serde_json::json!({
        "plugin": "acme.side", "graph": "other", "prefix": "",
    })).unwrap();
    assert!(scan2["keys"].as_array().unwrap().is_empty(), "{scan2}");
    // Canonical graph untouched by raw storage.
    let after = scc_engine::invoke(&root, "export.system_ir",
        serde_json::json!({"format": "system-ir.json"})).unwrap();
    assert_eq!(after["entities"].as_array().unwrap().len(), n_entities, "sidecar must not leak");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.promote-sidecar verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.promote-sidecar
fn promote_sidecar_enters_canonical_graph() {
    // §124 item 36 (promotion half): selected sidecar findings enter the
    // canonical graph through the normal contribution path. Core
    // predicates + existing endpoints + confidence in range succeed;
    // custom predicates, invented endpoints, and out-of-range confidence
    // fail loudly — no second universe, no silent downgrade.
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let store = scc_store::Store::open(&root.join(".scc").join("scc.db"), &root).unwrap();
    let ids: Vec<String> = store.all_entities().unwrap().into_iter().map(|e| e.id).collect();
    assert!(ids.len() >= 2, "need two endpoints: {ids:?}");
    let (a, b) = (ids[0].clone(), ids[1].clone());
    // Happy path: exact resolution stamps RESOLVED.
    let out = scc_engine::invoke(&root, "plugins.promote", serde_json::json!({
        "plugin": "acme.joern", "assertions": [
            {"subject": a, "predicate": "calls", "object": b,
             "confidence": 0.97, "exact": true},
        ],
    })).unwrap();
    assert_eq!(out["relationships"], serde_json::json!(1), "{out}");
    let rels = store.all_relationships().unwrap();
    let hit = rels.iter().find(|r| r.subject == a && r.object == b).unwrap();
    assert_eq!(hit.provenance, scc_core::Provenance::Resolved, "{hit:?}");
    // Custom predicate rejected (stays namespaced via contribute).
    let bad = scc_engine::invoke(&root, "plugins.promote", serde_json::json!({
        "plugin": "acme.joern", "assertions": [
            {"subject": a, "predicate": "plugin:acme/reaching_def", "object": b,
             "confidence": 0.9, "exact": true},
        ],
    }));
    assert!(bad.is_err(), "{bad:?}");
    // Invented endpoint rejected.
    let bad2 = scc_engine::invoke(&root, "plugins.promote", serde_json::json!({
        "plugin": "acme.joern", "assertions": [
            {"subject": "no-such-id", "predicate": "calls", "object": b,
             "confidence": 0.9, "exact": true},
        ],
    }));
    assert!(bad2.is_err(), "{bad2:?}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.diversity-policy verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.diversity-selection
fn diversity_policy_plugin_replaces_mmr() {
    // §124 item 24: a `diversity-policy` extension replaces MMR
    // wholesale. Default no-plugin path unchanged; the plugin answer is
    // honored verbatim.
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let ranked = |ids: &[&str]| -> Vec<serde_json::Value> {
        ids.iter().map(|id| serde_json::json!({
            "id": id, "kind": "symbol", "value": 1.0, "token_cost": 10})).collect()
    };
    let base = scc_engine::invoke(&root, "selection.mmr", serde_json::json!({
        "ranked": ranked(&["a", "b", "c"]), "budget": 30,
    })).unwrap();
    assert_eq!(base["selected"].as_array().unwrap().len(), 3, "{base}");
    let plugdir = root.join(".scc").join("plugins").join("acme.d");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.d\"\nname = \"D\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"selection.diversify\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"diversity-policy:acme.top1\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nprint(json.dumps({\"output\": {\"selected\": [\"b\"]}}))\n").unwrap();
    let out = scc_engine::invoke(&root, "selection.mmr", serde_json::json!({
        "ranked": ranked(&["a", "b", "c"]), "budget": 30,
    })).unwrap();
    assert_eq!(out["selected"], serde_json::json!(["b"]), "{out}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.budget-optimizer verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.budget-selection
fn budget_optimizer_plugin_replaces_selection() {
    // §124 item 27: a `budget-optimizer` extension replaces budget
    // selection wholesale. Default no-plugin path unchanged; the plugin
    // answer is honored verbatim; unknown ids fail loudly.
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let ranked = |ids: &[&str]| -> Vec<serde_json::Value> {
        ids.iter().map(|id| serde_json::json!({
            "id": id, "kind": "symbol", "value": 1.0, "token_cost": 100})).collect()
    };
    let base = scc_engine::invoke(&root, "selection.budget", serde_json::json!({
        "ranked": ranked(&["a", "b", "c"]), "budget": 300,
    })).unwrap();
    assert_eq!(base["selected"].as_array().unwrap().len(), 3, "{base}");
    let plugdir = root.join(".scc").join("plugins").join("acme.b");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.b\"\nname = \"B\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"selection.optimize\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"budget-optimizer:acme.cheap\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nprint(json.dumps({\"output\": {\"selected\": [\"c\"]}}))\n").unwrap();
    let out = scc_engine::invoke(&root, "selection.budget", serde_json::json!({
        "ranked": ranked(&["a", "b", "c"]), "budget": 300,
    })).unwrap();
    assert_eq!(out["selected"], serde_json::json!(["c"]), "{out}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.quota-policy verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.quota-overrides
fn quota_policy_plugin_overrides_fractions() {
    // §124 item 25 (quota half): a `quota-policy` extension overrides
    // per-kind fractions. Default no-plugin path unchanged (no
    // quota_overrides key); plugin wins per kind and is recorded.
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let ranked = |ids: &[&str]| -> Vec<serde_json::Value> {
        ids.iter().map(|id| serde_json::json!({
            "id": id, "kind": "symbol", "value": 1.0, "token_cost": 100})).collect()
    };
    // Baseline: no plugin, quota 0.5 over 300 tokens admits 1 of 3.
    let base = scc_engine::invoke(&root, "selection.quotas", serde_json::json!({
        "ranked": ranked(&["a", "b", "c"]),
        "quotas": [{"kind": "symbol", "fraction": 0.5}],
        "budget": 300,
    })).unwrap();
    assert!(base.get("quota_overrides").is_none(), "{base}");
    assert_eq!(base["selected"].as_array().unwrap().len(), 1, "{base}");
    // Plugin raises the fraction to 1.0: all three admitted, recorded.
    let plugdir = root.join(".scc").join("plugins").join("acme.q");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.q\"\nname = \"Q\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"selection.quotas\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"quota-policy:acme.lenient\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nprint(json.dumps({\"output\": {\"quotas\": [{\"kind\": \"symbol\", \"fraction\": 1.0}]}}))\n").unwrap();
    let out = scc_engine::invoke(&root, "selection.quotas", serde_json::json!({
        "ranked": ranked(&["a", "b", "c"]),
        "quotas": [{"kind": "symbol", "fraction": 0.5}],
        "budget": 300,
    })).unwrap();
    assert_eq!(out["selected"].as_array().unwrap().len(), 3, "{out}");
    assert_eq!(out["quota_overrides"], serde_json::json!([{"kind": "symbol", "fraction": 1.0}]), "{out}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.pipeline-rekey verifies=REQ-SI-503JSBGP exercises=impl.scc.startup.rank-cache-pipeline
fn pipeline_fragment_rekeys_rank_cache() {
    // §58: the rank cache keys on the ranking pipeline, not just the
    // model epoch. A plugin install must change the fragment, and two
    // fragments must load/store disjoint cache entries.
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    let cfg = scc_indexer::Config::default();
    scc_engine::index::full(&root, &cfg).unwrap();
    let store = scc_store::Store::open(&dir.path().join("scc.db"), &root).unwrap();
    let graph = scc_graph::RealityGraph::load(&store).unwrap();
    let settings = scc_context::ContextSettings::default();
    let stale: Vec<String> = Vec::new();
    let comp = scc_context::ContextCompiler::new(&store, &graph, settings, stale);
    let k0 = scc_engine::plugins::cache_key_fragment(
        &scc_engine::plugins::active(&root, &cfg));
    // A plugin install changes the fragment.
    let plugdir = root.join(".scc").join("plugins").join("acme.pipe");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.pipe\"\nname = \"Pipe\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = []\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let k1 = scc_engine::plugins::cache_key_fragment(
        &scc_engine::plugins::active(&root, &cfg));
    assert_ne!(k0, k1, "install must change the pipeline fragment");
    // Disjoint cache entries per pipeline at the same model epoch.
    // The entry mirrors the live compiler epoch/policy/salt (load
    // rejects mismatches by design); only the pipeline varies.
    let epoch = comp.store.cache_epoch().unwrap();
    let entry = scc_context::startup::GlobalRankCache {
        epoch: epoch.clone(), policy: scc_context::startup::trust_policy_str(comp.view.policy()),
        salt: comp.settings.rank_salt.clone(),
        global_vector: vec![1.0], node_symbol_map: Default::default(),
        candidates_epoch: epoch, candidate_ids: vec![],
        hits: 0,
    };
    scc_context::startup::store_global_rank_cache_with_pipeline(&comp, &entry, &k0);
    assert!(scc_context::startup::load_global_rank_cache_with_pipeline(&comp, &k0).is_some());
    assert!(scc_context::startup::load_global_rank_cache_with_pipeline(&comp, &k1).is_none(),
        "other pipeline must miss");
    assert!(scc_context::startup::load_global_rank_cache(&comp).is_none(),
        "legacy empty pipeline must miss a pipelined entry");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.viewer-panel verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.viewer-panels
fn viewer_panel_plugin_returns_structured_data() {
    // §124 item 32: a `viewer-panel:*` extension returns structured
    // title/html through `viewer.panels`, provenance-tagged. Empty html
    // contributes nothing (no empty panels).
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    let plugdir = root.join(".scc").join("plugins").join("acme.pan");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.pan\"\nname = \"Pan\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"viewer.panel\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"viewer-panel:acme.taint\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nprint(json.dumps({\"output\": {\"title\": \"Taint\", \"html\": \"<p>tainted</p>\"}}))\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let out = scc_engine::invoke(&root, "viewer.panels", serde_json::json!({})).unwrap();
    let panels = out["panels"].as_array().unwrap();
    assert_eq!(panels.len(), 1, "{out}");
    assert_eq!(panels[0]["id"], serde_json::json!("acme.taint"), "{out}");
    assert_eq!(panels[0]["plugin"], serde_json::json!("acme.pan"), "{out}");
    assert!(panels[0]["html"].as_str().unwrap().contains("tainted"), "{out}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.exporter verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-invoke.exporter-plugins
fn exporter_plugin_renders_unknown_format() {
    // §124 item 31: an `exporter:<format>` extension renders a format the
    // engine has no built-in for. Provenance tags the output; enabled
    // plugins never change a built-in format's bytes (no-plugin parity).
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    let plugdir = root.join(".scc").join("plugins").join("acme.exp");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.exp\"\nname = \"Exp\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"export.render\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"exporter:acme.sarif\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nprint(json.dumps({\"output\": {\"format-available\": True, \"text\": \"SARIF-OK\"}}))\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let out = scc_engine::invoke(&root, "export.system_ir", serde_json::json!({"format": "acme.sarif"})).unwrap();
    assert_eq!(out["output"], serde_json::json!("SARIF-OK"), "{out}");
    assert_eq!(out["plugin"], serde_json::json!("acme.exp"), "{out}");
    // Built-in formats are untouched by the enabled plugin.
    let native = scc_engine::invoke(&root, "export.system_ir", serde_json::json!({"format": "system-ir.json"})).unwrap();
    assert!(native.get("entities").is_some(), "{native}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.verify-diagnostic verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.verify-diagnostics
fn verify_diagnostic_plugin_appends_provenance_section() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    let plugdir = root.join(".scc").join("plugins").join("acme.ver");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.ver\"\nname = \"Ver\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"verify.diagnostic\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"verify-diagnostic:acme.stale\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nprint(json.dumps({\"output\": {\"diagnostic\": \"stale: x\"}}))\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let out = scc_engine::invoke(&root, "context.verify", serde_json::json!({})).unwrap();
    let content = out["content"].as_str().unwrap_or("");
    assert!(content.contains("# PLUGIN VERIFY DIAGNOSTIC acme.stale (from acme.ver"), "{content}");
    assert!(content.contains("stale: x"), "{content}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.startup-section verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.startup-sections
fn startup_section_plugin_appends_provenance_section() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    let plugdir = root.join(".scc").join("plugins").join("acme.boot");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.boot\"\nname = \"Boot\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"startup.section\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"startup-section:acme.banner\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nprint(json.dumps({\"output\": {\"section\": \"banner: hi\"}}))\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let out = scc_engine::invoke(&root, "context.startup", serde_json::json!({})).unwrap();
    let text = out["text"].as_str().unwrap_or("");
    assert!(text.contains("# PLUGIN STARTUP SECTION acme.banner (from acme.boot"), "{text}");
    assert!(text.contains("banner: hi"), "{text}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.context-section verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.context-sections
fn context_section_plugin_appends_provenance_section() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    let plugdir = root.join(".scc").join("plugins").join("acme.sec");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.sec\"\nname = \"Sec\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"context.section\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"context-section:acme.impact\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\ngoal = req[\"input\"].get(\"goal\", \"\")\nprint(json.dumps({\"output\": {\"section\": \"risk: \" + goal}}))\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let out = scc_engine::invoke(
        &root,
        "context.task",
        serde_json::json!({"goal": "hello", "files": [], "symbols": [], "budget": null, "hook": false}),
    )
    .unwrap();
    let content = out["pack"]["content"].as_str().unwrap_or("");
    assert!(content.contains("# PLUGIN SECTION acme.impact (from acme.sec"), "{content}");
    assert!(content.contains("risk: hello"), "{content}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.lockfile-round-trip verifies=REQ-SI-503JSBGP exercises=impl.scc-plugin-host.lockfile
fn plugin_lockfile_round_trip_and_drift() {
    let dir = tempfile::TempDir::new().unwrap();
    let root = write_plugin(dir.path());
    // No lockfile yet: check passes vacuously.
    let ap = scc_engine::plugins::active(&root, &scc_indexer::Config::default());
    assert!(scc_plugin_host::check_lockfile(&root, &ap.plugins).is_ok());
    // Write then verify: current.
    scc_plugin_host::write_lockfile(&root, &ap.plugins).unwrap();
    assert!(root.join(".scc").join("plugins.lock").is_file());
    let ap2 = scc_engine::plugins::active(&root, &scc_indexer::Config::default());
    assert!(scc_plugin_host::check_lockfile(&root, &ap2.plugins).is_ok());
    // Tamper the manifest version: drift names the plugin.
    let manifest = root.join(".scc").join("plugins").join("acme.echo").join("scc-plugin.toml");
    let text = std::fs::read_to_string(&manifest).unwrap().replace("1.0.0", "9.9.9");
    std::fs::write(&manifest, text).unwrap();
    let ap3 = scc_engine::plugins::active(&root, &scc_indexer::Config::default());
    let err = scc_plugin_host::check_lockfile(&root, &ap3.plugins).unwrap_err();
    assert!(err.contains("acme.echo"), "{err}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.lock-check-ops verifies=REQ-SI-503JSBGP exercises=impl.scc-plugin-host.lockfile-check
fn plugin_lock_and_check_ops_round_trip() {
    let dir = tempfile::TempDir::new().unwrap();
    let root = write_plugin(dir.path());
    let out = scc_engine::invoke(&root, "plugins.lock", serde_json::json!({})).unwrap();
    assert_eq!(out.get("ok"), Some(&serde_json::json!(true)), "{out}");
    let check = scc_engine::invoke(&root, "plugins.check", serde_json::json!({})).unwrap();
    assert_eq!(check.get("ok"), Some(&serde_json::json!(true)), "{check}");
}
#[test]
// trace:v1 id=test.scc-engine-plugins.inspect-alias verifies=REQ-SI-503JSBGP
fn plugin_inspect_aliases_describe() {
    // §29 names `inspect`; the op and CLI spell it `describe` elsewhere.
    // Both invoke arms resolve to the same manifest payload.
    let dir = tempfile::TempDir::new().unwrap();
    let root = write_plugin(dir.path());
    for op in ["plugins.describe", "plugins.inspect"] {
        let v = scc_engine::invoke(&root, op, serde_json::json!({"id": "acme.echo"})).unwrap();
        assert_eq!(v["manifest"]["id"], serde_json::json!("acme.echo"), "{op}: {v}");
    }
}

#[test]
// trace:v1 id=test.scc-engine-plugins.enable-disable verifies=REQ-SI-503JSBGP
fn plugin_enable_disable_round_trip() {
    // §29 enable/disable: the allow-list mutates .scc/config.yaml in
    // place; unknown ids fail loudly; other keys survive.
    let dir = tempfile::TempDir::new().unwrap();
    let root = write_plugin(dir.path());
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    std::fs::write(root.join(".scc").join("config.yaml"), "schema: 1\ncontext:\n  startup_tokens: 4242\n").unwrap();
    let en = scc_engine::invoke(&root, "plugins.enable", serde_json::json!({"id": "acme.echo"})).unwrap();
    assert_eq!(en["ok"], serde_json::json!(true), "{en}");
    assert!(en["enabled"].as_array().unwrap().iter().any(|x| x == "acme.echo"), "{en}");
    let text = std::fs::read_to_string(root.join(".scc").join("config.yaml")).unwrap();
    assert!(text.contains("startup_tokens: 4242"), "other keys survive: {text}");
    // Unknown id fails loudly.
    let e = scc_engine::invoke(&root, "plugins.enable", serde_json::json!({"id": "no.such"}));
    assert!(e.is_err(), "typo must fail: {e:?}");
    let dis = scc_engine::invoke(&root, "plugins.disable", serde_json::json!({"id": "acme.echo"})).unwrap();
    assert_eq!(dis["ok"], serde_json::json!(true), "{dis}");
    assert!(!dis["enabled"].as_array().unwrap().iter().any(|x| x == "acme.echo"), "{dis}");
}


#[test]
// trace:v1 id=test.scc-engine-plugins.edge-weight verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn edge_weight_contributor_alters_rank() {
    use std::io::Write;
    // zeta calls alpha: veto on the zeta->alpha edge starves alpha of flow.
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\ndef zeta():\n    return alpha()\n").unwrap();
    let plugdir = root.join(".scc").join("plugins").join("acme.edge");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.edge\"\nname = \"Edge\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"ranking.edge_weight\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"edge-weight:acme.edge\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    // Multiply every edge into alpha by 0.001 (near-starve, not veto: veto
    // would also drop reverse transitions and could disconnect the graph).
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nobj = req[\"input\"].get(\"object\", \"\")\nmode = \"multiply\" if \"alpha\" in obj else \"none\"\nprint(json.dumps({\"output\": {\"mode\": mode, \"value\": 0.001}}))\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let base = scc_engine::invoke(
        &root, "ranking.symbols",
        serde_json::json!({"goal": "", "limit": 10}),
    )
    .unwrap();
    let items = base["items"].as_array().unwrap();
    let pos = |sub: &str| items.iter().position(|i| i["id"].as_str().unwrap_or("").contains(sub)).unwrap();
    let base_alpha = pos("alpha");
    // Edge contributions are recorded: reasons + warning.
    assert!(
        items.iter().all(|i| i["reasons"].as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").starts_with("edge-weights("))),
        "edge-weight reasons recorded: {base}"
    );
    assert!(
        base["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("edge-weight")),
        "edge-weight warning recorded: {base}"
    );
    // Sanity: alpha still ranks (near-starve, not disconnect).
    assert!(base_alpha < items.len(), "alpha present: {base}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.state-crud verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn plugin_state_crud_is_namespaced_and_gated() {
    let dir = tempfile::TempDir::new().unwrap();
    let root = write_plugin(dir.path());
    // The echo fixture grants repo_read only: state.get must be denied.
    let denied = scc_engine::invoke(
        &root, "plugin_state.get",
        serde_json::json!({"plugin": "acme.echo", "key": "k"}),
    );
    assert!(denied.is_err(), "state without grant must fail: {denied:?}");
    // Grant state.read + state.write via project config file.
    std::fs::create_dir_all(root.join(".scc")).unwrap();
    std::fs::write(
        root.join(".scc").join("config.yaml"),
        "schema: 1\nplugins:\n  grants:\n    acme.echo: [state.read, state.write]\n",
    )
    .unwrap();
    let put = scc_engine::invoke(
        &root, "plugin_state.put",
        serde_json::json!({"plugin": "acme.echo", "key": "cursor", "value": {"n": 1}}),
    )
    .unwrap();
    assert_eq!(put.get("ok"), Some(&serde_json::json!(true)), "{put}");
    let got = scc_engine::invoke(
        &root, "plugin_state.get",
        serde_json::json!({"plugin": "acme.echo", "key": "cursor"}),
    )
    .unwrap();
    assert!(got.as_str().is_some_and(|s| s.contains('1')), "{got}");
    // Namespace isolation: another plugin id cannot see this key.
    let other = scc_engine::invoke(
        &root, "plugin_state.get",
        serde_json::json!({"plugin": "acme.other", "key": "cursor"}),
    );
    assert!(other.is_err(), "unknown plugin must fail: {other:?}");
    let scan = scc_engine::invoke(
        &root, "plugin_state.scan",
        serde_json::json!({"plugin": "acme.echo", "prefix": "cur", "limit": 10}),
    )
    .unwrap();
    assert_eq!(scan["keys"].as_array().map(|a| a.len()), Some(1), "{scan}");
    let del = scc_engine::invoke(
        &root, "plugin_state.delete",
        serde_json::json!({"plugin": "acme.echo", "key": "cursor"}),
    )
    .unwrap();
    assert_eq!(del.get("ok"), Some(&serde_json::json!(true)), "{del}");
    let gone = scc_engine::invoke(
        &root, "plugin_state.get",
        serde_json::json!({"plugin": "acme.echo", "key": "cursor"}),
    )
    .unwrap();
    assert!(gone.is_null(), "deleted key reads null: {gone}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.pagerank-edge-weights verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn pagerank_stage_ops_ignore_edge_weights() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\ndef zeta():\n    return alpha()\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let score_of = |v: &serde_json::Value, sub: &str| -> f64 {
        v["vector"].as_array().unwrap().iter()
            .find(|e| e["id"].as_str().unwrap_or("").contains(sub))
            .unwrap_or_else(|| panic!("{sub} missing: {v}"))["score"].as_f64().unwrap()
    };
    let plain_task = scc_engine::invoke(&root, "ranking.pagerank.task", serde_json::json!({"goal": "alpha"})).unwrap();
    let plain_global = scc_engine::invoke(&root, "ranking.pagerank.global", serde_json::json!({})).unwrap();
    // Add an edge-weight plugin: stage ops are raw introspection, so the
    // vectors must be byte-identical with the plugin present.
    let plugdir = root.join(".scc").join("plugins").join("acme.edge");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.edge\"\nname = \"Edge\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"ranking.edge_weight\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"edge-weight:acme.edge\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nobj = req[\"input\"].get(\"object\", \"\")\nmode = \"multiply\" if \"alpha\" in obj else \"none\"\nprint(json.dumps({\"output\": {\"mode\": mode, \"value\": 0.001}}))\n").unwrap();
    let hooked_task = scc_engine::invoke(&root, "ranking.pagerank.task", serde_json::json!({"goal": "alpha"})).unwrap();
    let hooked_global = scc_engine::invoke(&root, "ranking.pagerank.global", serde_json::json!({})).unwrap();
    // Float summation order is nondeterministic at the 1e-16 level, so
    // compare with tolerance: a live hook would move alpha massively
    // (0.001 edge multiply), noise stays far below 1e-9.
    for (label, plain, hooked) in [("task", &plain_task, &hooked_task), ("global", &plain_global, &hooked_global)] {
        for (pe, he) in plain["vector"].as_array().unwrap().iter().zip(hooked["vector"].as_array().unwrap()) {
            let (ps, hs) = (pe["score"].as_f64().unwrap(), he["score"].as_f64().unwrap());
            assert!((ps - hs).abs() < 1e-9, "raw {label} vector must ignore edge-weight hooks: {} {ps} vs {hs}", pe["id"]);
        }
    }
    let _ = score_of(&plain_task, "alpha");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.blend-profile verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn blend_profile_plugin_rescales_rank() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\ndef zeta():\n    return alpha()\n").unwrap();
    let plugdir = root.join(".scc").join("plugins").join("acme.prof");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.prof\"\nname = \"Prof\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"ranking.profile\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"blend-profile:change-risk\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    // Profile zeroes every weight except change_risk: with a no-goal
    // request all change_risk values are 0, so every rank must be 0 and
    // every item carries the profile reason.
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json\nprint(json.dumps({\"output\": {\"weights\": {\"task_ppr\": 0, \"global_ppr\": 0, \"lexical\": 0, \"semantic\": 0, \"confidence\": 0, \"criticality\": 0, \"change_risk\": 1, \"novelty\": 0}}}))\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let out = scc_engine::invoke(
        &root, "ranking.symbols",
        serde_json::json!({"goal": "", "limit": 10, "profile": "change-risk"}),
    )
    .unwrap();
    let items = out["items"].as_array().unwrap();
    assert!(!items.is_empty(), "profile rank must return items: {out}");
    // change_risk is 0 for every symbol here (clean tree) + novelty scaled:
    // total = blend*scale + novelty*weight(0) = 0 for all.
    assert!(items.iter().all(|i| i["rank"].as_f64().unwrap() == 0.0), "zeroed profile must zero ranks: {out}");
    assert!(items.iter().all(|i| i["reasons"].as_array().unwrap().iter().any(|r| r == "profile:change-risk")), "profile recorded: {out}");
    // Unknown profile still fails closed.
    let err = scc_engine::invoke(
        &root, "ranking.symbols",
        serde_json::json!({"goal": "", "limit": 10, "profile": "nope"}),
    );
    assert!(err.is_err(), "unknown profile must fail: {err:?}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.mmr-similarity verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn mmr_similarity_hook_diversifies() {
    // Default: same-group items are similar=1.0, so MMR with lambda=0
    // (pure diversity) picks across groups, not the top-two of one group.
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let ranked = serde_json::json!({"ranked": [
        {"id": "a1", "value": 0.9, "group": "g1"},
        {"id": "a2", "value": 0.8, "group": "g1"},
        {"id": "b1", "value": 0.7, "group": "g2"}
    ], "budget": 3, "lambda": 0.0});
    // budget = full list length; MMR order must interleave groups.
    let out = scc_engine::invoke(&root, "selection.mmr", ranked).unwrap();
    let sel = out["selected"].as_array().unwrap();
    let ids: Vec<&str> = sel.iter().map(|v| v.as_str().unwrap()).collect();
    assert_eq!(ids[0], "a1", "highest value first: {ids:?}");
    assert_eq!(ids[1], "b1", "diversity picks the other group second: {ids:?}");
    // Pure unit checks for the fold: plugin value wins, default last.
    assert_eq!(scc_engine::ranking::default_similarity(Some("x"), Some("x")), 1.0);
    assert_eq!(scc_engine::ranking::default_similarity(Some("x"), Some("y")), 0.0);
    assert_eq!(scc_engine::ranking::default_similarity(None, Some("x")), 0.0);
    assert_eq!(scc_engine::ranking::default_similarity(Some(""), Some("")), 0.0);
    let plug: scc_engine::ranking::SimilarityFn =
        std::sync::Arc::new(|_a, _b, _ga, _gb| 0.42);
    assert!((scc_engine::ranking::fold_similarity(&[plug], "a", "b", None, None) - 0.42).abs() < 1e-12);
    assert!((scc_engine::ranking::fold_similarity(&[], "a", "b", Some("g"), Some("g")) - 1.0).abs() < 1e-12);
}

#[test]
// trace:v1 id=test.scc-engine-plugins.unknown-grants verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn unknown_grant_names_surface_diagnostics() {
    // A typo'd grant must not silently narrow the set: it surfaces as a
    // diagnostic on the active set (visible via plugins.doctor), and the
    // known grants still apply.
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def hello():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let mut cfg = scc_indexer::Config::default();
    cfg.plugins.grants.insert(
        "any.plugin".into(),
        vec!["state.read".into(), "graph.write".into(), "repo_read".into()],
    );
    let ap = scc_engine::plugins::active(&root, &cfg);
    assert_eq!(ap.diagnostics.len(), 2, "both typos diagnosed: {:?}", ap.diagnostics);
    assert!(ap.diagnostics.iter().all(|d| d.plugin == "any.plugin"), "{:?}", ap.diagnostics);
    assert!(ap.diagnostics.iter().any(|d| d.error.contains("graph.write")), "{:?}", ap.diagnostics);
    // Capability alias (§27): `evidence.contribute` is a known grant name —
    // it parses alongside `graph.contribute` (same contribution scope).
    assert!(scc_plugin_api::Permission::parse("evidence.contribute").is_ok());
    assert!(scc_plugin_api::Permission::parse("graph.contribute").is_ok());
    // plugins.doctor surfaces the same diagnostics over invoke.
    std::fs::create_dir_all(root.join(".scc")).unwrap();
    std::fs::write(
        root.join(".scc").join("config.yaml"),
        "schema: 1\nplugins:\n  grants:\n    any.plugin: [state.read, graph.write, repo_read]\n",
    )
    .unwrap();
    let doc = scc_engine::invoke(&root, "plugins.doctor", serde_json::json!({})).unwrap();
    let diags = doc["diagnostics"].as_array().unwrap();
    assert!(diags.iter().any(|d| d["error"].as_str().unwrap_or("").contains("graph.write")), "{doc}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.candidate-provider verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn candidate_provider_merges_by_id() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\ndef zeta():\n    return alpha()\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    // Baseline: lexical candidates for a nonsense goal.
    let base = scc_engine::invoke(
        &root, "ranking.candidates",
        serde_json::json!({"goal": "zzz-no-match", "limit": 50}),
    )
    .unwrap();
    let base_ids: Vec<String> = base["candidates"].as_array().unwrap().iter()
        .map(|c| c["id"].as_str().unwrap().to_string()).collect();
    // Provider contributes one novel id + one overlapping id with a large
    // score (must win the merge), plus one empty id (must be skipped).
    let plugdir = root.join(".scc").join("plugins").join("acme.cand");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.cand\"\nname = \"Cand\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"ranking.candidates\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"candidate-provider:acme.cand\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    let overlap = base_ids.first().cloned().unwrap_or_else(|| "repo://repo/symbol/main.py/alpha".into());
    let body = format!(
        "import json\nprint(json.dumps({{\"output\": {{\"candidates\": [{{\"id\": \"plugin:acme/novel\", \"kind\": \"symbol\", \"name\": \"novel\", \"score\": 99.0}}, {{\"id\": \"{overlap}\", \"kind\": \"symbol\", \"name\": \"x\", \"score\": 99.0}}, {{\"id\": \"\", \"kind\": \"symbol\", \"name\": \"empty\", \"score\": 99.0}}]}}}}))\n"
    );
    f.write_all(body.as_bytes()).unwrap();
    let hooked = scc_engine::invoke(
        &root, "ranking.candidates",
        serde_json::json!({"goal": "zzz-no-match", "limit": 50}),
    )
    .unwrap();
    let cands = hooked["candidates"].as_array().unwrap();
    assert!(cands.iter().any(|c| c["id"] == "plugin:acme/novel"), "novel id merged: {hooked}");
    let novel = cands.iter().find(|c| c["id"] == "plugin:acme/novel").unwrap();
    assert_eq!(novel["reason"], serde_json::json!("plugin:acme.cand"), "provider provenance: {hooked}");
    let over = cands.iter().find(|c| c["id"].as_str() == Some(overlap.as_str())).unwrap();
    assert_eq!(over["score"].as_f64().unwrap(), 99.0, "max score wins: {hooked}");
    assert!(cands.iter().all(|c| !c["id"].as_str().unwrap_or("").is_empty()), "empty ids skipped");
    // Deterministic order: scores desc, ties by id.
    let scores: Vec<f64> = cands.iter().map(|c| c["score"].as_f64().unwrap()).collect();
    assert!(scores.windows(2).all(|w| w[0] >= w[1]), "sorted desc: {scores:?}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.evidence-provider verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-state.plugin-evidence
fn evidence_provider_plugin_commits_batch() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    // Evidence-provider plugin: answers evidence.import with a batch
    // carrying one custom-kind entity + one evidence record.
    let plugdir = root.join(".scc").join("plugins").join("acme.ev");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.ev\"\nname = \"Ev\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"evidence.import\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"evidence-provider:acme.ev\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    )
    .unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json\nprint(json.dumps({\"output\": {\"batch\": {\"entities\": [{\"id\": \"plugin:acme.ev/finding/1\", \"kind\": \"plugin:acme.ev/finding\", \"name\": \"finding-1\"}], \"relationships\": [], \"evidence\": [{\"id\": \"evidence:acme-ev-1\", \"type\": \"source\"}], \"diagnostics\": []}}}))\n").unwrap();
    let out = scc_engine::invoke(&root, "import.acme.ev", serde_json::json!({"file": "main.py"})).unwrap();
    assert_eq!(out.get("symbols").and_then(|v| v.as_u64()), Some(1), "one entity committed: {out}");
    assert_eq!(out.get("imports").and_then(|v| v.as_u64()), Some(1), "one evidence committed: {out}");
    // Unknown plugin id fails with the import vocabulary, not "unknown operation".
    let err = scc_engine::invoke(&root, "import.acme.ghost", serde_json::json!({"file": "main.py"}));
    assert!(err.is_err(), "unknown plugin must fail: {err:?}");
    assert!(err.unwrap_err().to_string().contains("unknown import format"), "import vocabulary");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.rank-edge verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn rank_edge_provider_adds_diffusion_without_canonical_facts() {
    use std::io::Write;
    // zeta calls alpha: a rank-edge zeta->alpha must move alpha's score
    // without writing any canonical relationship.
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\ndef zeta():\n    return alpha()\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let score_of = |v: &serde_json::Value, sub: &str| -> f64 {
        v["items"].as_array().unwrap().iter()
            .find(|i| i["id"].as_str().unwrap_or("").contains(sub))
            .unwrap_or_else(|| panic!("{sub} missing: {v}"))["rank"].as_f64().unwrap()
    };
    let rel_count = scc_engine::invoke(&root, "graph.relationships", serde_json::json!({})).unwrap();
    let n_rels = rel_count.as_array().unwrap().len();
    let base = scc_engine::invoke(&root, "ranking.symbols",
        serde_json::json!({"goal": "", "limit": 10})).unwrap();
    assert!(base.get("warnings").and_then(|v| v.as_array()).map(|w| w.is_empty()).unwrap_or(true), "no edge warnings yet: {base}");
    // The plugin needs real universe node ids: read them from ranking.edges.
    let edges = scc_engine::invoke(&root, "ranking.edges", serde_json::json!({})).unwrap();
    let ids: Vec<String> = edges["edges"].as_array().unwrap().iter()
        .flat_map(|e| [e["subject"].as_str().unwrap_or(""), e["object"].as_str().unwrap_or("")])
        .map(str::to_string).collect();
    let has = |sub: &str| ids.iter().find(|i| i.contains(sub)).cloned().unwrap();
    let (zeta, alpha) = (has("zeta"), has("alpha"));
    let plugdir = root.join(".scc").join("plugins").join("acme.redge");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.redge\"\nname = \"Redge\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"ranking.rank_edges\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"rank-edge:acme.redge\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    // One heavy rank-time edge + one dangling id (must degrade, not fail).
    let body = format!(
        "import json\nprint(json.dumps({{\"output\": {{\"edges\": [{{\"subject\": \"{zeta}\", \"predicate\": \"calls\", \"object\": \"{alpha}\", \"weight\": 50.0}}, {{\"subject\": \"repo://nowhere/x\", \"predicate\": \"calls\", \"object\": \"repo://nowhere/y\", \"weight\": 99.0}}]}}}}))\n"
    );
    f.write_all(body.as_bytes()).unwrap();
    let out = scc_engine::invoke(&root, "ranking.symbols",
        serde_json::json!({"goal": "", "limit": 10})).unwrap();
    assert!(
        out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("rank-time edge")),
        "rank-edge warning recorded: {out}"
    );
    assert!(
        out["items"].as_array().unwrap().iter().all(|i| i["reasons"].as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").starts_with("rank-edges("))),
        "rank-edge reasons recorded: {out}"
    );
    assert!(
        (score_of(&out, "alpha") - score_of(&base, "alpha")).abs() > 1e-9,
        "extra edge moved alpha's score"
    );
    // No canonical facts written: relationship count identical.
    let after = scc_engine::invoke(&root, "graph.relationships", serde_json::json!({})).unwrap();
    assert_eq!(after.as_array().unwrap().len(), n_rels, "rank edges never canonical");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.novelty-criticality verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn novelty_and_criticality_providers_override_blend() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\ndef zeta():\n    return alpha()\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let feat_of = |v: &serde_json::Value, sub: &str| -> serde_json::Value {
        v["features"].as_array().unwrap().iter()
            .find(|f| f["id"].as_str().unwrap_or("").contains(sub)).unwrap().clone()
    };
    let base = scc_engine::invoke(&root, "ranking.features",
        serde_json::json!({"goal": "", "limit": 10})).unwrap();
    assert_eq!(feat_of(&base, "alpha")["novelty"].as_f64().unwrap(), 1.0, "default novelty 1.0: {base}");
    // Criticality provider: 0.0 for everything; novelty provider: 0.0 for
    // everything; plus a bad provider answering 99.0 (must abstain).
    for (pid, ext, op, key) in [
        ("acme.crit", "criticality-provider:acme.c", "ranking.criticality", "criticality"),
        ("acme.nov", "novelty-provider:acme.n", "ranking.novelty", "novelty"),
        ("acme.bad", "novelty-provider:acme.b", "ranking.novelty", "novelty"),
    ] {
        let plugdir = root.join(".scc").join("plugins").join(pid);
        std::fs::create_dir_all(&plugdir).unwrap();
        std::fs::write(
            plugdir.join("scc-plugin.toml"),
            format!("[plugin]\nid = \"{pid}\"\nname = \"P\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"{op}\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"{ext}\" = {{priority=1}}\n\n[permissions]\nrepo_read = true\n"),
        ).unwrap();
        let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
        let val = if pid == "acme.bad" { "99.0" } else { "0.0" };
        let body = format!("import json\nprint(json.dumps({{\"output\": {{\"{key}\": {val}}}}}))\n");
        f.write_all(body.as_bytes()).unwrap();
    }
    let out = scc_engine::invoke(&root, "ranking.features",
        serde_json::json!({"goal": "", "limit": 10})).unwrap();
    let alpha = feat_of(&out, "alpha");
    assert_eq!(alpha["criticality"].as_f64().unwrap(), 0.0, "criticality overridden: {out}");
    // Novelty 0.0 wins over the bad 99.0 (first-Some-wins in chain order:
    // acme.nov sorts before acme.bad — but either way 99.0 abstains).
    assert_eq!(alpha["novelty"].as_f64().unwrap(), 0.0, "novelty overridden, bad value abstained: {out}");
    let items = scc_engine::invoke(&root, "ranking.symbols",
        serde_json::json!({"goal": "", "limit": 10})).unwrap();
    assert!(
        items["items"].as_array().unwrap().iter().all(|i|
            i["reasons"].as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").starts_with("criticality:provider"))
            && i["reasons"].as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").starts_with("novelty:provider"))),
        "override sources recorded: {items}"
    );
}

#[test]
// trace:v1 id=test.scc-engine-plugins.risk-provider verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn risk_provider_overrides_change_risk() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\ndef zeta():\n    return alpha()\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    // Fresh tree: default change_risk is 0.0 everywhere.
    let base = scc_engine::invoke(&root, "ranking.features",
        serde_json::json!({"goal": "", "limit": 10})).unwrap();
    assert!(
        base["features"].as_array().unwrap().iter().all(|f| f["change_risk"].as_f64().unwrap() == 0.0),
        "fresh tree has no risk: {base}"
    );
    // Risk provider answers 1.0 for symbols containing "alpha", abstains
    // (missing key) otherwise.
    let plugdir = root.join(".scc").join("plugins").join("acme.risk");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.risk\"\nname = \"R\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"ranking.risk\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"risk-provider:acme.r\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nsym = req[\"input\"].get(\"symbol\", \"\")\nval = 1.0 if \"alpha\" in sym else None\nout = {\"risk\": val} if val is not None else {}\nprint(json.dumps({\"output\": out}))\n").unwrap();
    let out = scc_engine::invoke(&root, "ranking.features",
        serde_json::json!({"goal": "", "limit": 10})).unwrap();
    let alpha = out["features"].as_array().unwrap().iter()
        .find(|f| f["id"].as_str().unwrap_or("").contains("alpha")).unwrap();
    let zeta = out["features"].as_array().unwrap().iter()
        .find(|f| f["id"].as_str().unwrap_or("").contains("zeta")).unwrap();
    assert_eq!(alpha["change_risk"].as_f64().unwrap(), 1.0, "alpha risk overridden: {out}");
    assert_eq!(zeta["change_risk"].as_f64().unwrap(), 0.0, "zeta abstains to default: {out}");
    let items = scc_engine::invoke(&root, "ranking.symbols",
        serde_json::json!({"goal": "", "limit": 10})).unwrap();
    assert!(
        items["items"].as_array().unwrap().iter()
            .find(|i| i["id"].as_str().unwrap_or("").contains("alpha")).unwrap()["reasons"]
            .as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").starts_with("risk:provider")),
        "risk source recorded: {items}"
    );
}

#[test]
// trace:v1 id=test.scc-engine-plugins.semantic-provider verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn semantic_provider_feeds_blend_slot() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\ndef zeta():\n    return alpha()\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    // No scorer configured: default semantic is 0.0 everywhere.
    let base = scc_engine::invoke(&root, "ranking.features",
        serde_json::json!({"goal": "alpha", "limit": 10})).unwrap();
    assert!(
        base["features"].as_array().unwrap().iter().all(|f| f["semantic"].as_f64().unwrap() == 0.0),
        "no scorer, no semantic: {base}"
    );
    // Semantic provider answers 1.0 for alpha, abstains otherwise.
    let plugdir = root.join(".scc").join("plugins").join("acme.sem");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.sem\"\nname = \"S\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"ranking.semantic\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"semantic-provider:acme.s\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json, sys\nreq = json.load(sys.stdin)\nsym = req[\"input\"].get(\"symbol\", \"\")\nval = 1.0 if \"alpha\" in sym else None\nout = {\"semantic\": val} if val is not None else {}\nprint(json.dumps({\"output\": out}))\n").unwrap();
    let out = scc_engine::invoke(&root, "ranking.features",
        serde_json::json!({"goal": "alpha", "limit": 10})).unwrap();
    let alpha = out["features"].as_array().unwrap().iter()
        .find(|f| f["id"].as_str().unwrap_or("").contains("alpha")).unwrap();
    let zeta = out["features"].as_array().unwrap().iter()
        .find(|f| f["id"].as_str().unwrap_or("").contains("zeta")).unwrap();
    assert_eq!(alpha["semantic"].as_f64().unwrap(), 1.0, "alpha semantic fed: {out}");
    assert_eq!(zeta["semantic"].as_f64().unwrap(), 0.0, "zeta abstains to default: {out}");
    let items = scc_engine::invoke(&root, "ranking.symbols",
        serde_json::json!({"goal": "alpha", "limit": 10})).unwrap();
    assert!(
        items["items"].as_array().unwrap().iter()
            .find(|i| i["id"].as_str().unwrap_or("").contains("alpha")).unwrap()["reasons"]
            .as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").starts_with("semantic:provider")),
        "semantic source recorded: {items}"
    );
}

#[test]
// trace:v1 id=test.scc-engine-plugins.runtime-evidence verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-state.plugin-runtime
fn runtime_evidence_plugin_edges_land_in_status() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    // Baseline: no runtime edges.
    let base = scc_engine::invoke(&root, "runtime.status", serde_json::json!({})).unwrap();
    assert!(base.as_array().unwrap().is_empty(), "no edges yet: {base}");
    // Runtime-evidence plugin contributes one edge per ingest; plus a
    // crashing provider that must degrade silently.
    for (pid, ext, body) in [
        ("acme.rt", "runtime-evidence:acme.r",
         "import json, sys\nprint(json.dumps({\"output\": {\"edges\": [{\"source\": \"web\", \"target\": \"api\", \"count\": 3}]}}))\n"),
        ("acme.dead", "runtime-evidence:acme.d", "import sys\nsys.exit(7)\n"),
    ] {
        let plugdir = root.join(".scc").join("plugins").join(pid);
        std::fs::create_dir_all(&plugdir).unwrap();
        std::fs::write(
            plugdir.join("scc-plugin.toml"),
            format!("[plugin]\nid = \"{pid}\"\nname = \"P\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"runtime.evidence\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"{ext}\" = {{priority=1}}\n\n[permissions]\nrepo_read = true\n"),
        ).unwrap();
        let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
        f.write_all(body.as_bytes()).unwrap();
    }
    let out = scc_engine::invoke(&root, "runtime.ingest", serde_json::json!({"body": "[]"})).unwrap();
    assert_eq!(out["status"], serde_json::json!("accepted"), "{out}");
    let st = scc_engine::invoke(&root, "runtime.status", serde_json::json!({})).unwrap();
    let hit = st.as_array().unwrap().iter()
        .find(|e| e["source"] == "web" && e["target"] == "api").unwrap();
    assert_eq!(hit["count"].as_u64().unwrap(), 3, "plugin edge landed: {st}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.component-signal verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn component_signal_plugin_nominates_component() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::create_dir_all(root.join("acme")).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\n").unwrap();
    std::fs::write(root.join("acme").join("widget.py"), "def widget():\n    return 3\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let base = scc_engine::invoke(&root, "graph.entities", serde_json::json!({})).unwrap();
    assert!(
        base.as_array().unwrap().iter().all(|e| e["name"] != "acme-widget"),
        "no widget component before the plugin: {base}"
    );
    // Plugin nominates a new component + one malformed entry (must abstain).
    let plugdir = root.join(".scc").join("plugins").join("acme.comp");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.comp\"\nname = \"Comp\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"components.signals\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"component-signal:acme.c\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json\nprint(json.dumps({\"output\": {\"signals\": [{\"name\": \"acme-widget\", \"dirs\": [\"acme\"]}, {\"name\": \"\", \"dirs\": []}]}}))\n").unwrap();
    scc_engine::invoke(&root, "graph.recompile", serde_json::json!({})).unwrap();
    let after = scc_engine::invoke(&root, "graph.entities", serde_json::json!({})).unwrap();
    let widget = after.as_array().unwrap().iter()
        .find(|e| e["name"] == "acme-widget")
        .unwrap_or_else(|| panic!("plugin component landed: {after}"));
    assert_eq!(widget["attributes"]["boundary_kind"], serde_json::json!("plugin"), "{widget}");
}

#[test]
// trace:v1 id=test.scc-engine-plugins.rank-node verifies=REQ-SI-503JSBGP exercises=impl.scc-engine-plugins.call-operation
fn rank_node_plugin_extends_universe_without_canonical_facts() {
    use std::io::Write;
    let dir = tempfile::TempDir::new().unwrap();
    let root = dir.path().join("repo");
    std::fs::create_dir_all(&root).unwrap();
    std::fs::write(root.join("main.py"), "def alpha():\n    return 1\n").unwrap();
    scc_engine::index::full(&root, &scc_indexer::Config::default()).unwrap();
    let base = scc_engine::invoke(&root, "ranking.universe", serde_json::json!({})).unwrap();
    let n_base = base["nodes"].as_array().unwrap().len();
    let n_entities = scc_engine::invoke(&root, "graph.entities", serde_json::json!({})).unwrap();
    let n_ents = n_entities.as_array().unwrap().len();
    // Plugin contributes one rankable node + one unknown kind (abstains)
    // + one empty id (abstains).
    let plugdir = root.join(".scc").join("plugins").join("acme.rnode");
    std::fs::create_dir_all(&plugdir).unwrap();
    std::fs::write(
        plugdir.join("scc-plugin.toml"),
        "[plugin]\nid = \"acme.rnode\"\nname = \"Rnode\"\nversion = \"1.0.0\"\napi = \"1\"\noperations = [\"ranking.rank_nodes\"]\n\n[runtime]\ncommand = [\"python3\", \"plugin.py\"]\n\n[extensions]\n\"rank-node:acme.rnode\" = {priority=1}\n\n[permissions]\nrepo_read = true\n",
    ).unwrap();
    let mut f = std::fs::File::create(plugdir.join("plugin.py")).unwrap();
    f.write_all(b"import json\nprint(json.dumps({\"output\": {\"nodes\": [{\"id\": \"plugin://acme/hotspot\", \"kind\": \"symbol\"}, {\"id\": \"plugin://acme/weird\", \"kind\": \"nonsense\"}, {\"id\": \"\", \"kind\": \"symbol\"}]}}))\n").unwrap();
    let out = scc_engine::invoke(&root, "ranking.universe", serde_json::json!({})).unwrap();
    let ids: Vec<&str> = out["nodes"].as_array().unwrap().iter()
        .map(|n| n["id"].as_str().unwrap_or("")).collect();
    assert_eq!(ids.len(), n_base + 1, "exactly one node admitted: {out}");
    assert!(ids.contains(&"plugin://acme/hotspot"), "{out}");
    // Reasons + warnings recorded on the ranked path.
    let syms = scc_engine::invoke(&root, "ranking.symbols",
        serde_json::json!({"goal": "", "limit": 10})).unwrap();
    assert!(
        syms["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("rank-universe node")),
        "node warning recorded: {syms}"
    );
    assert!(
        syms["items"].as_array().unwrap().iter().all(|i| i["reasons"].as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").starts_with("rank-nodes("))),
        "node reasons recorded: {syms}"
    );
    // No canonical facts written: entity count identical.
    let after = scc_engine::invoke(&root, "graph.entities", serde_json::json!({})).unwrap();
    assert_eq!(after.as_array().unwrap().len(), n_ents, "rank nodes never canonical");
}