1use serde::{Deserialize, Serialize};
9use sha2::Digest as _;
10use std::borrow::Cow;
11use std::collections::BTreeMap;
12use std::collections::HashMap;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum VersionPin {
22 Npm {
24 package: String,
25 version: String,
26 registry_url: String,
27 },
28 Git {
30 repo: String,
31 path: Option<String>,
32 commit: Option<String>,
33 },
34 Url { url: String },
36 Checksum,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum ExtensionCategory {
44 Tool,
46 Command,
48 Provider,
50 #[serde(alias = "event-hook")]
52 EventHook,
53 #[serde(alias = "ui")]
55 UiComponent,
56 #[serde(alias = "shortcut", alias = "flag")]
58 Configuration,
59 Multi,
61 #[serde(alias = "basic", alias = "exec", alias = "session", alias = "unknown")]
63 General,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct InclusionEntry {
73 pub id: String,
74 #[serde(default)]
75 pub name: Option<String>,
76 #[serde(default)]
77 pub tier: Option<String>,
78 #[serde(default)]
79 pub score: Option<f64>,
80 pub category: ExtensionCategory,
81 #[serde(default)]
83 pub registrations: Vec<String>,
84 #[serde(default)]
85 pub version_pin: Option<VersionPin>,
86 #[serde(default)]
87 pub sha256: Option<String>,
88 #[serde(default)]
89 pub artifact_path: Option<String>,
90 #[serde(default)]
91 pub license: Option<String>,
92 #[serde(default)]
93 pub source_tier: Option<String>,
94 #[serde(default)]
95 pub rationale: Option<String>,
96 #[serde(default)]
98 pub directory: Option<String>,
99 #[serde(default)]
100 pub provenance: Option<serde_json::Value>,
101 #[serde(default)]
102 pub capabilities: Option<Vec<String>>,
103 #[serde(default)]
104 pub risk_level: Option<String>,
105 #[serde(default)]
106 pub inclusion_rationale: Option<String>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ExclusionNote {
112 pub id: String,
113 pub score: f64,
114 pub reason: String,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct InclusionList {
124 pub schema: String,
125 pub generated_at: String,
126 #[serde(default)]
128 pub task: Option<String>,
129 #[serde(default)]
130 pub stats: Option<InclusionStats>,
131 #[serde(default)]
132 pub tier0: Vec<InclusionEntry>,
133 #[serde(default)]
134 pub tier1: Vec<InclusionEntry>,
135 #[serde(default)]
136 pub tier2: Vec<InclusionEntry>,
137 #[serde(default)]
138 pub exclusions: Vec<ExclusionNote>,
139 #[serde(default)]
140 pub category_coverage: HashMap<String, usize>,
141 #[serde(default)]
143 pub summary: Option<serde_json::Value>,
144 #[serde(default)]
145 pub tier1_review: Vec<InclusionEntry>,
146 #[serde(default)]
147 pub coverage: Option<serde_json::Value>,
148 #[serde(default)]
149 pub exclusion_notes: Vec<ExclusionNote>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct InclusionStats {
155 pub total_included: usize,
156 pub tier0_count: usize,
157 pub tier1_count: usize,
158 pub tier2_count: usize,
159 pub excluded_count: usize,
160 pub pinned_npm: usize,
161 pub pinned_git: usize,
162 pub pinned_url: usize,
163 pub pinned_checksum_only: usize,
164}
165
166#[must_use]
172pub fn classify_registrations(registrations: &[String]) -> ExtensionCategory {
173 let has_tool = registrations.iter().any(|r| r == "registerTool");
174 let has_cmd = registrations
175 .iter()
176 .any(|r| r == "registerCommand" || r == "registerSlashCommand");
177 let has_provider = registrations.iter().any(|r| r == "registerProvider");
178 let has_event = registrations
179 .iter()
180 .any(|r| r == "registerEvent" || r == "registerEventHook");
181 let has_ui = registrations.iter().any(|r| r == "registerMessageRenderer");
182 let has_configuration = registrations
183 .iter()
184 .any(|r| r == "registerFlag" || r == "registerShortcut");
185
186 let distinct = [
187 has_tool,
188 has_cmd,
189 has_provider,
190 has_event,
191 has_ui,
192 has_configuration,
193 ]
194 .iter()
195 .filter(|&&x| x)
196 .count();
197
198 if distinct > 1 {
199 return ExtensionCategory::Multi;
200 }
201
202 if has_tool {
203 ExtensionCategory::Tool
204 } else if has_cmd {
205 ExtensionCategory::Command
206 } else if has_provider {
207 ExtensionCategory::Provider
208 } else if has_event {
209 ExtensionCategory::EventHook
210 } else if has_ui {
211 ExtensionCategory::UiComponent
212 } else if has_configuration {
213 ExtensionCategory::Configuration
214 } else {
215 ExtensionCategory::General
216 }
217}
218
219#[must_use]
221pub fn build_rationale(
222 tier: &str,
223 score: f64,
224 category: &ExtensionCategory,
225 source_tier: &str,
226) -> String {
227 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
228 let score_u = score as u32;
229 let tier_reason: Cow<'_, str> = match tier {
230 "tier-0" => Cow::Borrowed("Official pi-mono baseline; must-pass conformance target"),
231 "tier-1" => Cow::Owned(format!("High score ({score_u}/100); passes all gates")),
232 "tier-2" => Cow::Owned(format!(
233 "Moderate score ({score_u}/100); stretch conformance target"
234 )),
235 _ => Cow::Borrowed("Excluded"),
236 };
237
238 let cat_reason = match category {
239 ExtensionCategory::Tool => "Covers tool registration path",
240 ExtensionCategory::Command => "Covers command/slash-command registration",
241 ExtensionCategory::Provider => "Covers custom provider registration",
242 ExtensionCategory::EventHook => "Covers event hook lifecycle",
243 ExtensionCategory::UiComponent => "Covers UI component rendering",
244 ExtensionCategory::Configuration => "Covers flag/shortcut configuration",
245 ExtensionCategory::Multi => "Multi-type: covers multiple registration paths",
246 ExtensionCategory::General => "General extension (export default)",
247 };
248
249 let source_reason = match source_tier {
250 "official-pi-mono" => "official",
251 "community" | "agents-mikeastock" => "community",
252 "npm-registry" | "npm-registry-pi" => "npm",
253 _ => source_tier,
254 };
255
256 format!("{tier_reason}. {cat_reason}. Source: {source_reason}.")
257}
258
259#[must_use]
264pub fn canonicalize_json_value(value: &serde_json::Value) -> serde_json::Value {
265 match value {
266 serde_json::Value::Object(map) => {
267 let sorted = map
268 .iter()
269 .map(|(k, v)| (k.clone(), canonicalize_json_value(v)))
270 .collect::<BTreeMap<_, _>>();
271
272 let mut out = serde_json::Map::with_capacity(sorted.len());
273 for (k, v) in sorted {
274 out.insert(k, v);
275 }
276 serde_json::Value::Object(out)
277 }
278 serde_json::Value::Array(items) => {
279 serde_json::Value::Array(items.iter().map(canonicalize_json_value).collect())
280 }
281 _ => value.clone(),
282 }
283}
284
285#[must_use]
290pub fn normalize_manifest_value(value: &serde_json::Value) -> serde_json::Value {
291 let mut normalized = canonicalize_json_value(value);
292 if let Some(obj) = normalized.as_object_mut() {
293 obj.remove("generated_at");
294 }
295 normalized
296}
297
298pub fn normalized_manifest_hash(json: &str) -> Result<String, serde_json::Error> {
303 let value: serde_json::Value = serde_json::from_str(json)?;
304 normalized_manifest_hash_from_value(&value)
305}
306
307pub fn normalized_manifest_hash_from_value(
309 value: &serde_json::Value,
310) -> Result<String, serde_json::Error> {
311 let normalized = normalize_manifest_value(value);
312 let bytes = serde_json::to_vec(&normalized)?;
313 let mut hasher = sha2::Sha256::new();
314 hasher.update(&bytes);
315 Ok(format!("{:x}", hasher.finalize()))
316}
317
318#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn classify_single_tool() {
328 assert_eq!(
329 classify_registrations(&["registerTool".into()]),
330 ExtensionCategory::Tool
331 );
332 }
333
334 #[test]
335 fn classify_single_command() {
336 assert_eq!(
337 classify_registrations(&["registerCommand".into()]),
338 ExtensionCategory::Command
339 );
340 }
341
342 #[test]
343 fn classify_provider() {
344 assert_eq!(
345 classify_registrations(&["registerProvider".into()]),
346 ExtensionCategory::Provider
347 );
348 }
349
350 #[test]
351 fn classify_multi() {
352 assert_eq!(
353 classify_registrations(&["registerTool".into(), "registerCommand".into()]),
354 ExtensionCategory::Multi
355 );
356 }
357
358 #[test]
359 fn classify_empty() {
360 assert_eq!(classify_registrations(&[]), ExtensionCategory::General);
361 }
362
363 #[test]
364 fn classify_flag_is_configuration() {
365 assert_eq!(
366 classify_registrations(&["registerFlag".into()]),
367 ExtensionCategory::Configuration
368 );
369 }
370
371 #[test]
372 fn classify_event() {
373 assert_eq!(
374 classify_registrations(&["registerEventHook".into()]),
375 ExtensionCategory::EventHook
376 );
377 }
378
379 #[test]
380 fn classify_renderer() {
381 assert_eq!(
382 classify_registrations(&["registerMessageRenderer".into()]),
383 ExtensionCategory::UiComponent
384 );
385 }
386
387 #[test]
388 fn classify_unknown_then_known_prefers_known_category() {
389 assert_eq!(
390 classify_registrations(&["registerUnknown".into(), "registerProvider".into()]),
391 ExtensionCategory::Provider
392 );
393 }
394
395 #[test]
396 fn classify_configuration_plus_tool_is_multi() {
397 assert_eq!(
398 classify_registrations(&["registerFlag".into(), "registerTool".into()]),
399 ExtensionCategory::Multi
400 );
401 }
402
403 #[test]
404 fn rationale_tier0() {
405 let r = build_rationale("tier-0", 60.0, &ExtensionCategory::Tool, "official-pi-mono");
406 assert!(r.contains("Official pi-mono baseline"));
407 assert!(r.contains("tool registration"));
408 assert!(r.contains("official"));
409 }
410
411 #[test]
412 fn rationale_tier2() {
413 let r = build_rationale("tier-2", 52.0, &ExtensionCategory::Provider, "community");
414 assert!(r.contains("52/100"));
415 assert!(r.contains("custom provider"));
416 }
417
418 #[test]
419 fn rationale_tier1_includes_score_without_leak_pattern() {
420 let r = build_rationale("tier-1", 87.0, &ExtensionCategory::Tool, "community");
421 assert!(r.contains("87/100"));
422 assert!(r.contains("passes all gates"));
423 }
424
425 #[test]
426 fn inclusion_entry_serde_round_trip() {
427 let entry = InclusionEntry {
428 id: "test/ext".into(),
429 name: Some("Test Extension".into()),
430 tier: Some("tier-0".into()),
431 score: Some(60.0),
432 category: ExtensionCategory::Tool,
433 registrations: vec!["registerTool".into()],
434 version_pin: Some(VersionPin::Git {
435 repo: "https://github.com/test/ext".into(),
436 path: Some("extensions/test".into()),
437 commit: None,
438 }),
439 sha256: Some("abc123".into()),
440 artifact_path: Some("tests/ext_conformance/artifacts/test".into()),
441 license: Some("MIT".into()),
442 source_tier: Some("official-pi-mono".into()),
443 rationale: Some("Official baseline".into()),
444 directory: None,
445 provenance: None,
446 capabilities: None,
447 risk_level: None,
448 inclusion_rationale: None,
449 };
450 let json = serde_json::to_string(&entry).unwrap();
451 let back: InclusionEntry = serde_json::from_str(&json).unwrap();
452 assert_eq!(back.id, "test/ext");
453 assert_eq!(back.category, ExtensionCategory::Tool);
454 }
455
456 #[test]
457 fn npm_version_pin_serde() {
458 let pin = VersionPin::Npm {
459 package: "@oh-my-pi/test".into(),
460 version: "1.0.0".into(),
461 registry_url: "https://registry.npmjs.org".into(),
462 };
463 let json = serde_json::to_string(&pin).unwrap();
464 assert!(json.contains("npm"));
465 assert!(json.contains("1.0.0"));
466 }
467
468 #[test]
469 fn inclusion_list_serde() {
470 let list = InclusionList {
471 schema: "pi.ext.inclusion.v1".into(),
472 generated_at: "2026-01-01T00:00:00Z".into(),
473 task: Some("test".into()),
474 stats: Some(InclusionStats {
475 total_included: 0,
476 tier0_count: 0,
477 tier1_count: 0,
478 tier2_count: 0,
479 excluded_count: 0,
480 pinned_npm: 0,
481 pinned_git: 0,
482 pinned_url: 0,
483 pinned_checksum_only: 0,
484 }),
485 tier0: vec![],
486 tier1: vec![],
487 tier2: vec![],
488 exclusions: vec![],
489 category_coverage: HashMap::new(),
490 summary: None,
491 tier1_review: vec![],
492 coverage: None,
493 exclusion_notes: vec![],
494 };
495 let json = serde_json::to_string(&list).unwrap();
496 let back: InclusionList = serde_json::from_str(&json).unwrap();
497 assert_eq!(back.schema, "pi.ext.inclusion.v1");
498 }
499
500 #[test]
501 fn normalized_manifest_hash_ignores_generated_at_and_key_order() {
502 let first = serde_json::json!({
503 "schema": "pi.ext.inclusion_list.v1",
504 "generated_at": "2026-02-10T00:00:00Z",
505 "summary": {
506 "tier1_count": 2,
507 "tier2_count": 1
508 },
509 "tier1": [{"id": "a"}, {"id": "b"}]
510 });
511
512 let second = serde_json::json!({
513 "tier1": [{"id": "a"}, {"id": "b"}],
514 "summary": {
515 "tier2_count": 1,
516 "tier1_count": 2
517 },
518 "generated_at": "2030-01-01T12:34:56Z",
519 "schema": "pi.ext.inclusion_list.v1"
520 });
521
522 let first_hash = normalized_manifest_hash_from_value(&first).unwrap();
523 let second_hash = normalized_manifest_hash_from_value(&second).unwrap();
524 assert_eq!(first_hash, second_hash);
525 }
526
527 #[test]
528 fn normalized_manifest_hash_detects_content_changes() {
529 let baseline = serde_json::json!({
530 "schema": "pi.ext.inclusion_list.v1",
531 "generated_at": "2026-02-10T00:00:00Z",
532 "summary": { "tier1_count": 2 }
533 });
534 let changed = serde_json::json!({
535 "schema": "pi.ext.inclusion_list.v1",
536 "generated_at": "2026-02-10T00:00:00Z",
537 "summary": { "tier1_count": 3 }
538 });
539
540 let baseline_hash = normalized_manifest_hash_from_value(&baseline).unwrap();
541 let changed_hash = normalized_manifest_hash_from_value(&changed).unwrap();
542 assert_ne!(baseline_hash, changed_hash);
543 }
544
545 mod proptest_extension_inclusion {
546 use super::*;
547 use proptest::prelude::*;
548
549 const REG_TYPES: &[&str] = &[
551 "registerTool",
552 "registerCommand",
553 "registerSlashCommand",
554 "registerProvider",
555 "registerEvent",
556 "registerEventHook",
557 "registerMessageRenderer",
558 "registerFlag",
559 "registerShortcut",
560 ];
561
562 proptest! {
563 #[test]
565 fn classify_never_panics(
566 n in 0..10usize,
567 seed in prop::collection::vec("[a-zA-Z]{1,20}", 0..10)
568 ) {
569 let _ = classify_registrations(&seed[..n.min(seed.len())]);
570 }
571
572 #[test]
574 fn empty_registrations_is_general(_dummy in 0..1u8) {
575 assert_eq!(classify_registrations(&[]), ExtensionCategory::General);
576 }
577
578 #[test]
580 fn single_registration_specific(idx in 0..REG_TYPES.len()) {
581 let regs = vec![REG_TYPES[idx].to_string()];
582 let cat = classify_registrations(®s);
583 assert_ne!(cat, ExtensionCategory::Multi);
584 assert_ne!(cat, ExtensionCategory::General);
585 }
586
587 #[test]
589 fn two_distinct_returns_multi(
590 idx_a in 0..1usize, idx_b in 3..4usize ) {
593 let regs = vec![
594 REG_TYPES[idx_a].to_string(),
595 REG_TYPES[idx_b].to_string(),
596 ];
597 assert_eq!(classify_registrations(®s), ExtensionCategory::Multi);
598 }
599
600 #[test]
602 fn unknown_registrations_general(s in "[a-z]{5,15}") {
603 if !REG_TYPES.contains(&s.as_str()) {
605 assert_eq!(
606 classify_registrations(&[s]),
607 ExtensionCategory::General
608 );
609 }
610 }
611
612 #[test]
614 fn duplicates_idempotent(idx in 0..REG_TYPES.len()) {
615 let single = vec![REG_TYPES[idx].to_string()];
616 let doubled = vec![REG_TYPES[idx].to_string(), REG_TYPES[idx].to_string()];
617 assert_eq!(
618 classify_registrations(&single),
619 classify_registrations(&doubled)
620 );
621 }
622
623 #[test]
625 fn category_serde_roundtrip(idx in 0..8usize) {
626 let cats = [
627 ExtensionCategory::Tool,
628 ExtensionCategory::Command,
629 ExtensionCategory::Provider,
630 ExtensionCategory::EventHook,
631 ExtensionCategory::UiComponent,
632 ExtensionCategory::Configuration,
633 ExtensionCategory::Multi,
634 ExtensionCategory::General,
635 ];
636 let cat = &cats[idx];
637 let json = serde_json::to_string(cat).unwrap();
638 let back: ExtensionCategory = serde_json::from_str(&json).unwrap();
639 assert_eq!(*cat, back);
640 }
641
642 #[test]
644 fn rationale_never_panics(
645 tier_idx in 0..4usize,
646 score in 0.0f64..100.0,
647 cat_idx in 0..8usize,
648 source in "[a-z-]{1,20}"
649 ) {
650 let tiers = ["tier-0", "tier-1", "tier-2", "unknown"];
651 let cats = [
652 ExtensionCategory::Tool,
653 ExtensionCategory::Command,
654 ExtensionCategory::Provider,
655 ExtensionCategory::EventHook,
656 ExtensionCategory::UiComponent,
657 ExtensionCategory::Configuration,
658 ExtensionCategory::Multi,
659 ExtensionCategory::General,
660 ];
661 let result = build_rationale(tiers[tier_idx], score, &cats[cat_idx], &source);
662 assert!(!result.is_empty());
663 assert!(result.ends_with('.'));
664 }
665
666 #[test]
668 fn canonicalize_idempotent(
669 key1 in "[a-z]{1,5}",
670 key2 in "[a-z]{1,5}",
671 val1 in 0i64..100,
672 val2 in 0i64..100
673 ) {
674 let obj = serde_json::json!({ &key2: val2, &key1: val1 });
675 let once = canonicalize_json_value(&obj);
676 let twice = canonicalize_json_value(&once);
677 assert_eq!(once, twice);
678 }
679
680 #[test]
682 fn canonicalize_sorts_keys(
683 key1 in "[a-z]{1,5}",
684 key2 in "[a-z]{1,5}"
685 ) {
686 let obj = serde_json::json!({ &key2: 1, &key1: 2 });
687 let canonical = canonicalize_json_value(&obj);
688 let keys: Vec<&String> = canonical.as_object().unwrap().keys().collect();
689 for w in keys.windows(2) {
690 assert!(w[0] <= w[1], "keys not sorted: {keys:?}");
691 }
692 }
693
694 #[test]
696 fn canonicalize_preserves_primitives(n in -1000i64..1000) {
697 let val = serde_json::Value::from(n);
698 assert_eq!(canonicalize_json_value(&val), val);
699 }
700
701 #[test]
703 fn normalize_removes_generated_at(ts in "[0-9]{4}-[0-9]{2}-[0-9]{2}") {
704 let obj = serde_json::json!({
705 "schema": "test",
706 "generated_at": ts,
707 "data": 42
708 });
709 let norm = normalize_manifest_value(&obj);
710 assert!(norm.get("generated_at").is_none());
711 assert!(norm.get("data").is_some());
712 }
713
714 #[test]
716 fn hash_is_64_hex(key in "[a-z]{1,10}", val in 0i64..1000) {
717 let json = serde_json::json!({ &key: val }).to_string();
718 let hash = normalized_manifest_hash(&json).unwrap();
719 assert_eq!(hash.len(), 64);
720 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
721 }
722
723 #[test]
725 fn hash_deterministic(key in "[a-z]{1,5}", val in 0i64..100) {
726 let json = serde_json::json!({ &key: val }).to_string();
727 let h1 = normalized_manifest_hash(&json).unwrap();
728 let h2 = normalized_manifest_hash(&json).unwrap();
729 assert_eq!(h1, h2);
730 }
731
732 #[test]
734 fn hash_ignores_key_order(
735 k1 in "[a-m]{1,3}",
736 k2 in "[n-z]{1,3}"
737 ) {
738 let a = format!(r#"{{"{k1}":1,"{k2}":2}}"#);
739 let b = format!(r#"{{"{k2}":2,"{k1}":1}}"#);
740 assert_eq!(
741 normalized_manifest_hash(&a).unwrap(),
742 normalized_manifest_hash(&b).unwrap()
743 );
744 }
745
746 #[test]
748 fn hash_ignores_generated_at(ts1 in "[0-9]{10}", ts2 in "[0-9]{10}") {
749 let a = serde_json::json!({"generated_at": ts1, "x": 1});
750 let b = serde_json::json!({"generated_at": ts2, "x": 1});
751 assert_eq!(
752 normalized_manifest_hash_from_value(&a).unwrap(),
753 normalized_manifest_hash_from_value(&b).unwrap()
754 );
755 }
756
757 #[test]
759 fn hash_invalid_json_errs(s in "[a-z]{5,20}") {
760 assert!(normalized_manifest_hash(&s).is_err());
761 }
762 }
763 }
764}