Skip to main content

pi/
conformance.rs

1//! Conformance utilities for fixture- and diff-based validation.
2//!
3//! This module is primarily intended for test harnesses that compare outputs
4//! across runtimes (e.g., TS oracle vs Rust implementation) in a way that is
5//! robust to irrelevant differences like ordering or float representation.
6#![forbid(unsafe_code)]
7
8use serde_json::Value;
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt::Write as _;
11
12const FLOAT_EPSILON: f64 = 1e-10;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15enum DiffKind {
16    Root,
17    Registration,
18    Hostcall,
19    Event,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23struct DiffItem {
24    kind: DiffKind,
25    path: String,
26    message: String,
27}
28
29impl DiffItem {
30    fn new(kind: DiffKind, path: impl Into<String>, message: impl Into<String>) -> Self {
31        Self {
32            kind,
33            path: path.into(),
34            message: message.into(),
35        }
36    }
37}
38
39/// Compare two conformance outputs (TS vs Rust) with semantic rules.
40///
41/// Rules (high-level):
42/// - Registration lists are compared ignoring ordering, keyed by their identity
43///   field (e.g., command `name`, shortcut `key_id`).
44/// - Hostcall logs are compared with order preserved.
45/// - Objects are compared ignoring key ordering.
46/// - Missing vs `null` is treated as equivalent.
47/// - Missing vs empty array (`[]`) is treated as equivalent.
48/// - Floats compare with epsilon (`1e-10`).
49///
50/// Returns `Ok(())` if semantically equal; otherwise returns a human-readable
51/// diff report.
52pub fn compare_conformance_output(expected: &Value, actual: &Value) -> Result<(), String> {
53    let mut diffs = Vec::new();
54    compare_conformance_output_inner(expected, actual, &mut diffs);
55    if diffs.is_empty() {
56        Ok(())
57    } else {
58        Err(render_diffs(&diffs))
59    }
60}
61
62fn compare_conformance_output_inner(expected: &Value, actual: &Value, diffs: &mut Vec<DiffItem>) {
63    compare_string_field(expected, actual, "extension_id", DiffKind::Root, diffs);
64    compare_string_field(expected, actual, "name", DiffKind::Root, diffs);
65    compare_string_field(expected, actual, "version", DiffKind::Root, diffs);
66
67    // Registrations
68    let expected_regs = expected.get("registrations");
69    let actual_regs = actual.get("registrations");
70    compare_registrations(expected_regs, actual_regs, diffs);
71
72    // Hostcall log (order matters)
73    compare_hostcall_log(
74        expected.get("hostcall_log"),
75        actual.get("hostcall_log"),
76        diffs,
77    );
78
79    // Optional: event results, if the runner includes them.
80    compare_optional_semantic_value(
81        expected.get("events"),
82        actual.get("events"),
83        "events",
84        DiffKind::Event,
85        diffs,
86    );
87}
88
89fn compare_string_field(
90    expected: &Value,
91    actual: &Value,
92    key: &str,
93    kind: DiffKind,
94    diffs: &mut Vec<DiffItem>,
95) {
96    let left = expected.get(key).and_then(Value::as_str);
97    let right = actual.get(key).and_then(Value::as_str);
98    if left != right {
99        diffs.push(DiffItem::new(
100            kind,
101            key,
102            format!("expected {left:?}, got {right:?}"),
103        ));
104    }
105}
106
107fn compare_registrations(
108    expected: Option<&Value>,
109    actual: Option<&Value>,
110    diffs: &mut Vec<DiffItem>,
111) {
112    let expected = expected.unwrap_or(&Value::Null);
113    let actual = actual.unwrap_or(&Value::Null);
114    let Some(expected_obj) = expected.as_object() else {
115        diffs.push(DiffItem::new(
116            DiffKind::Registration,
117            "registrations",
118            "expected an object",
119        ));
120        return;
121    };
122    let Some(actual_obj) = actual.as_object() else {
123        diffs.push(DiffItem::new(
124            DiffKind::Registration,
125            "registrations",
126            "actual is not an object",
127        ));
128        return;
129    };
130
131    compare_keyed_registration_list(
132        expected_obj.get("commands"),
133        actual_obj.get("commands"),
134        "name",
135        "registrations.commands",
136        diffs,
137    );
138    compare_keyed_registration_list(
139        expected_obj.get("tool_defs"),
140        actual_obj.get("tool_defs"),
141        "name",
142        "registrations.tool_defs",
143        diffs,
144    );
145    compare_keyed_registration_list(
146        expected_obj.get("flags"),
147        actual_obj.get("flags"),
148        "name",
149        "registrations.flags",
150        diffs,
151    );
152    compare_keyed_registration_list(
153        expected_obj.get("providers"),
154        actual_obj.get("providers"),
155        "name",
156        "registrations.providers",
157        diffs,
158    );
159    compare_keyed_registration_list(
160        expected_obj.get("shortcuts"),
161        actual_obj.get("shortcuts"),
162        "key_id",
163        "registrations.shortcuts",
164        diffs,
165    );
166    compare_keyed_registration_list(
167        expected_obj.get("models"),
168        actual_obj.get("models"),
169        "id",
170        "registrations.models",
171        diffs,
172    );
173
174    // event_hooks: treat as set of strings.
175    let expected_hooks = expected_obj.get("event_hooks");
176    let actual_hooks = actual_obj.get("event_hooks");
177    compare_string_set(
178        expected_hooks,
179        actual_hooks,
180        "registrations.event_hooks",
181        diffs,
182    );
183}
184
185fn compare_keyed_registration_list(
186    expected: Option<&Value>,
187    actual: Option<&Value>,
188    key_field: &str,
189    path: &str,
190    diffs: &mut Vec<DiffItem>,
191) {
192    let expected_items = value_as_array_or_empty(expected, path, diffs, DiffKind::Registration);
193    let actual_items = value_as_array_or_empty(actual, path, diffs, DiffKind::Registration);
194
195    let expected_map = index_by_string_key(&expected_items, key_field, path, diffs);
196    let actual_map = index_by_string_key(&actual_items, key_field, path, diffs);
197
198    let mut keys = BTreeSet::new();
199    keys.extend(expected_map.keys().cloned());
200    keys.extend(actual_map.keys().cloned());
201
202    for key in keys {
203        let expected_value = expected_map.get(&key);
204        let actual_value = actual_map.get(&key);
205        match (expected_value, actual_value) {
206            (Some(_), None) => diffs.push(DiffItem::new(
207                DiffKind::Registration,
208                format!("{path}[{key_field}={key}]"),
209                "missing in actual",
210            )),
211            (None, Some(_)) => diffs.push(DiffItem::new(
212                DiffKind::Registration,
213                format!("{path}[{key_field}={key}]"),
214                "extra in actual",
215            )),
216            (Some(left), Some(right)) => {
217                compare_semantic_value(
218                    left,
219                    right,
220                    &format!("{path}[{key_field}={key}]"),
221                    Some(key_field),
222                    DiffKind::Registration,
223                    diffs,
224                );
225            }
226            (None, None) => {}
227        }
228    }
229}
230
231fn compare_string_set(
232    expected: Option<&Value>,
233    actual: Option<&Value>,
234    path: &str,
235    diffs: &mut Vec<DiffItem>,
236) {
237    let expected_items = value_as_array_or_empty(expected, path, diffs, DiffKind::Registration);
238    let actual_items = value_as_array_or_empty(actual, path, diffs, DiffKind::Registration);
239
240    let expected_set = expected_items
241        .iter()
242        .filter_map(Value::as_str)
243        .map(str::to_string)
244        .collect::<BTreeSet<_>>();
245    let actual_set = actual_items
246        .iter()
247        .filter_map(Value::as_str)
248        .map(str::to_string)
249        .collect::<BTreeSet<_>>();
250
251    if expected_set == actual_set {
252        return;
253    }
254
255    let missing = expected_set
256        .difference(&actual_set)
257        .cloned()
258        .collect::<Vec<_>>();
259    let extra = actual_set
260        .difference(&expected_set)
261        .cloned()
262        .collect::<Vec<_>>();
263
264    if !missing.is_empty() {
265        diffs.push(DiffItem::new(
266            DiffKind::Registration,
267            path,
268            format!("missing: {}", missing.join(", ")),
269        ));
270    }
271    if !extra.is_empty() {
272        diffs.push(DiffItem::new(
273            DiffKind::Registration,
274            path,
275            format!("extra: {}", extra.join(", ")),
276        ));
277    }
278}
279
280fn compare_hostcall_log(
281    expected: Option<&Value>,
282    actual: Option<&Value>,
283    diffs: &mut Vec<DiffItem>,
284) {
285    let path = "hostcall_log";
286    let expected_items = value_as_array_or_empty(expected, path, diffs, DiffKind::Hostcall);
287    let actual_items = value_as_array_or_empty(actual, path, diffs, DiffKind::Hostcall);
288
289    if expected_items.len() != actual_items.len() {
290        diffs.push(DiffItem::new(
291            DiffKind::Hostcall,
292            path,
293            format!(
294                "length mismatch: expected {}, got {}",
295                expected_items.len(),
296                actual_items.len()
297            ),
298        ));
299    }
300
301    let count = expected_items.len().min(actual_items.len());
302    for idx in 0..count {
303        let left = &expected_items[idx];
304        let right = &actual_items[idx];
305        compare_semantic_value(
306            left,
307            right,
308            &format!("{path}[{idx}]"),
309            None,
310            DiffKind::Hostcall,
311            diffs,
312        );
313    }
314}
315
316fn compare_optional_semantic_value(
317    expected: Option<&Value>,
318    actual: Option<&Value>,
319    path: &str,
320    kind: DiffKind,
321    diffs: &mut Vec<DiffItem>,
322) {
323    if expected.is_none() && actual.is_none() {
324        return;
325    }
326    let left = expected.unwrap_or(&Value::Null);
327    let right = actual.unwrap_or(&Value::Null);
328    compare_semantic_value(left, right, path, None, kind, diffs);
329}
330
331fn value_as_array_or_empty(
332    value: Option<&Value>,
333    path: &str,
334    diffs: &mut Vec<DiffItem>,
335    kind: DiffKind,
336) -> Vec<Value> {
337    match value {
338        None | Some(Value::Null) => Vec::new(),
339        Some(Value::Array(items)) => items.clone(),
340        Some(other) => {
341            diffs.push(DiffItem::new(
342                kind,
343                path,
344                format!("expected array, got {}", json_type_name(other)),
345            ));
346            Vec::new()
347        }
348    }
349}
350
351fn index_by_string_key(
352    items: &[Value],
353    key_field: &str,
354    path: &str,
355    diffs: &mut Vec<DiffItem>,
356) -> BTreeMap<String, Value> {
357    let mut out = BTreeMap::new();
358    for (idx, item) in items.iter().enumerate() {
359        let key = item
360            .get(key_field)
361            .and_then(Value::as_str)
362            .map_or("", str::trim);
363        if key.is_empty() {
364            diffs.push(DiffItem::new(
365                DiffKind::Registration,
366                format!("{path}[{idx}]"),
367                format!("missing string key field {key_field:?}"),
368            ));
369            continue;
370        }
371        if out.contains_key(key) {
372            // Silently skip duplicate keys within a single side.
373            // Emitting diffs here would break reflexivity: comparing X to
374            // itself would fail whenever duplicates exist, because both
375            // the expected and actual side would each push a diff item.
376            continue;
377        }
378        out.insert(key.to_string(), item.clone());
379    }
380    out
381}
382
383fn compare_semantic_value(
384    expected: &Value,
385    actual: &Value,
386    path: &str,
387    parent_key: Option<&str>,
388    kind: DiffKind,
389    diffs: &mut Vec<DiffItem>,
390) {
391    // Missing vs null / empty array equivalence is handled at object-key union sites.
392
393    match (expected, actual) {
394        (Value::Null, Value::Null) => {}
395        (Value::Bool(left), Value::Bool(right)) => {
396            if left != right {
397                diffs.push(DiffItem::new(kind, path, format!("{left} != {right}")));
398            }
399        }
400        (Value::Number(left), Value::Number(right)) => {
401            if !numbers_equal(left, right) {
402                diffs.push(DiffItem::new(
403                    kind,
404                    path,
405                    format!("expected {left}, got {right}"),
406                ));
407            }
408        }
409        (Value::String(left), Value::String(right)) => {
410            if left != right {
411                diffs.push(DiffItem::new(
412                    kind,
413                    path,
414                    format!("expected {left:?}, got {right:?}"),
415                ));
416            }
417        }
418        (Value::Array(left), Value::Array(right)) => {
419            if array_order_insensitive(parent_key) {
420                compare_unordered_array(left, right, path, kind, diffs);
421            } else {
422                compare_ordered_array(left, right, path, kind, diffs);
423            }
424        }
425        (Value::Object(left), Value::Object(right)) => {
426            let mut keys = BTreeSet::new();
427            keys.extend(left.keys().cloned());
428            keys.extend(right.keys().cloned());
429
430            for key in keys {
431                let left_value = left.get(&key);
432                let right_value = right.get(&key);
433                if missing_equals_null_or_empty_array(left_value, right_value) {
434                    continue;
435                }
436                let left_value = left_value.unwrap_or(&Value::Null);
437                let right_value = right_value.unwrap_or(&Value::Null);
438                compare_semantic_value(
439                    left_value,
440                    right_value,
441                    &format!("{path}.{key}"),
442                    Some(key.as_str()),
443                    kind,
444                    diffs,
445                );
446            }
447        }
448        _ => {
449            if missing_equals_null_or_empty_array(Some(expected), Some(actual)) {
450                return;
451            }
452            diffs.push(DiffItem::new(
453                kind,
454                path,
455                format!(
456                    "type mismatch: expected {}, got {}",
457                    json_type_name(expected),
458                    json_type_name(actual)
459                ),
460            ));
461        }
462    }
463}
464
465fn compare_ordered_array(
466    expected: &[Value],
467    actual: &[Value],
468    path: &str,
469    kind: DiffKind,
470    diffs: &mut Vec<DiffItem>,
471) {
472    if expected.len() != actual.len() {
473        diffs.push(DiffItem::new(
474            kind,
475            path,
476            format!(
477                "length mismatch: expected {}, got {}",
478                expected.len(),
479                actual.len()
480            ),
481        ));
482    }
483    let count = expected.len().min(actual.len());
484    for idx in 0..count {
485        compare_semantic_value(
486            &expected[idx],
487            &actual[idx],
488            &format!("{path}[{idx}]"),
489            None,
490            kind,
491            diffs,
492        );
493    }
494}
495
496fn compare_unordered_array(
497    expected: &[Value],
498    actual: &[Value],
499    path: &str,
500    kind: DiffKind,
501    diffs: &mut Vec<DiffItem>,
502) {
503    let mut left = expected.to_vec();
504    let mut right = actual.to_vec();
505    left.sort_by_key(stable_value_key);
506    right.sort_by_key(stable_value_key);
507    compare_ordered_array(&left, &right, path, kind, diffs);
508}
509
510fn stable_value_key(value: &Value) -> String {
511    match value {
512        Value::Null => "null".to_string(),
513        Value::Bool(v) => format!("bool:{v}"),
514        Value::Number(v) => format!("num:{v}"),
515        Value::String(v) => format!("str:{v}"),
516        Value::Array(items) => {
517            let mut out = String::new();
518            out.push_str("arr:[");
519            for (idx, item) in items.iter().enumerate() {
520                if idx > 0 {
521                    out.push(',');
522                }
523                out.push_str(&stable_value_key(item));
524            }
525            out.push(']');
526            out
527        }
528        Value::Object(map) => {
529            let mut keys = map.keys().cloned().collect::<Vec<_>>();
530            keys.sort();
531            let mut out = String::new();
532            out.push_str("obj:{");
533            for key in keys {
534                out.push_str(&key);
535                out.push('=');
536                if let Some(value) = map.get(&key) {
537                    out.push_str(&stable_value_key(value));
538                }
539                out.push(';');
540            }
541            out.push('}');
542            out
543        }
544    }
545}
546
547fn array_order_insensitive(parent_key: Option<&str>) -> bool {
548    matches!(parent_key, Some("required" | "input" | "event_hooks"))
549}
550
551fn missing_equals_null_or_empty_array(left: Option<&Value>, right: Option<&Value>) -> bool {
552    match (left, right) {
553        (None | Some(Value::Null), None) | (None, Some(Value::Null)) => true,
554        (None, Some(Value::Array(items))) | (Some(Value::Array(items)), None) => items.is_empty(),
555        _ => false,
556    }
557}
558
559fn numbers_equal(left: &serde_json::Number, right: &serde_json::Number) -> bool {
560    if left == right {
561        return true;
562    }
563    let left = left.as_f64();
564    let right = right.as_f64();
565    match (left, right) {
566        (Some(left), Some(right)) => (left - right).abs() <= FLOAT_EPSILON + f64::EPSILON,
567        _ => false,
568    }
569}
570
571const fn json_type_name(value: &Value) -> &'static str {
572    match value {
573        Value::Null => "null",
574        Value::Bool(_) => "bool",
575        Value::Number(_) => "number",
576        Value::String(_) => "string",
577        Value::Array(_) => "array",
578        Value::Object(_) => "object",
579    }
580}
581
582fn render_diffs(diffs: &[DiffItem]) -> String {
583    let mut grouped: BTreeMap<DiffKind, Vec<&DiffItem>> = BTreeMap::new();
584    for diff in diffs {
585        grouped.entry(diff.kind).or_default().push(diff);
586    }
587
588    let mut out = String::new();
589    for (kind, items) in grouped {
590        let header = match kind {
591            DiffKind::Root => "ROOT",
592            DiffKind::Registration => "REGISTRATION",
593            DiffKind::Hostcall => "HOSTCALL",
594            DiffKind::Event => "EVENT",
595        };
596        let _ = writeln!(out, "== {header} DIFFS ==");
597        for item in items {
598            let _ = writeln!(out, "- {}: {}", item.path, item.message);
599        }
600        out.push('\n');
601    }
602    out
603}
604
605// ============================================================================
606// Conformance Report Generation (bd-2jha)
607// ============================================================================
608
609pub mod report {
610    use chrono::{SecondsFormat, Utc};
611    use serde::{Deserialize, Serialize};
612    use std::collections::BTreeMap;
613    use std::fmt::Write as _;
614
615    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
616    #[serde(rename_all = "snake_case")]
617    pub enum ConformanceStatus {
618        Pass,
619        Fail,
620        Skip,
621        Error,
622    }
623
624    impl ConformanceStatus {
625        #[must_use]
626        pub const fn as_upper_str(self) -> &'static str {
627            match self {
628                Self::Pass => "PASS",
629                Self::Fail => "FAIL",
630                Self::Skip => "SKIP",
631                Self::Error => "ERROR",
632            }
633        }
634    }
635
636    #[derive(Debug, Clone, Serialize, Deserialize)]
637    #[serde(rename_all = "camelCase")]
638    pub struct ConformanceDiffEntry {
639        pub category: String,
640        pub path: String,
641        pub message: String,
642    }
643
644    #[derive(Debug, Clone, Serialize, Deserialize)]
645    #[serde(rename_all = "camelCase")]
646    pub struct ExtensionConformanceResult {
647        pub id: String,
648        #[serde(default, skip_serializing_if = "Option::is_none")]
649        pub tier: Option<u32>,
650        pub status: ConformanceStatus,
651        #[serde(default, skip_serializing_if = "Option::is_none")]
652        pub ts_time_ms: Option<u64>,
653        #[serde(default, skip_serializing_if = "Option::is_none")]
654        pub rust_time_ms: Option<u64>,
655        #[serde(default, skip_serializing_if = "Vec::is_empty")]
656        pub diffs: Vec<ConformanceDiffEntry>,
657        #[serde(default, skip_serializing_if = "Option::is_none")]
658        pub notes: Option<String>,
659    }
660
661    fn ratio(passed: u64, total: u64) -> f64 {
662        if total == 0 {
663            0.0
664        } else {
665            #[allow(clippy::cast_precision_loss)]
666            {
667                passed as f64 / total as f64
668            }
669        }
670    }
671
672    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
673    #[serde(rename_all = "camelCase")]
674    pub struct TierSummary {
675        pub total: u64,
676        pub passed: u64,
677        pub failed: u64,
678        pub skipped: u64,
679        pub errors: u64,
680        pub pass_rate: f64,
681    }
682
683    impl TierSummary {
684        fn from_results(results: &[ExtensionConformanceResult]) -> Self {
685            let total = results.len() as u64;
686            let passed = results
687                .iter()
688                .filter(|r| r.status == ConformanceStatus::Pass)
689                .count() as u64;
690            let failed = results
691                .iter()
692                .filter(|r| r.status == ConformanceStatus::Fail)
693                .count() as u64;
694            let skipped = results
695                .iter()
696                .filter(|r| r.status == ConformanceStatus::Skip)
697                .count() as u64;
698            let errors = results
699                .iter()
700                .filter(|r| r.status == ConformanceStatus::Error)
701                .count() as u64;
702
703            let pass_rate = ratio(passed, total);
704
705            Self {
706                total,
707                passed,
708                failed,
709                skipped,
710                errors,
711                pass_rate,
712            }
713        }
714    }
715
716    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
717    #[serde(rename_all = "camelCase")]
718    pub struct ConformanceSummary {
719        pub total: u64,
720        pub passed: u64,
721        pub failed: u64,
722        pub skipped: u64,
723        pub errors: u64,
724        pub pass_rate: f64,
725        pub by_tier: BTreeMap<String, TierSummary>,
726    }
727
728    #[derive(Debug, Clone, Serialize, Deserialize)]
729    #[serde(rename_all = "camelCase")]
730    pub struct ConformanceReport {
731        pub run_id: String,
732        pub timestamp: String,
733        pub summary: ConformanceSummary,
734        pub extensions: Vec<ExtensionConformanceResult>,
735    }
736
737    #[derive(Debug, Clone, Serialize, Deserialize)]
738    #[serde(rename_all = "camelCase")]
739    pub struct ExtensionRegression {
740        pub id: String,
741        pub previous: ConformanceStatus,
742        #[serde(default, skip_serializing_if = "Option::is_none")]
743        pub current: Option<ConformanceStatus>,
744    }
745
746    #[derive(Debug, Clone, Default, Serialize, Deserialize)]
747    #[serde(rename_all = "camelCase")]
748    pub struct ConformanceRegression {
749        /// Number of extensions compared for pass-rate deltas.
750        pub compared_total: u64,
751        pub previous_passed: u64,
752        pub current_passed: u64,
753        pub previous_pass_rate: f64,
754        pub current_pass_rate: f64,
755        pub pass_rate_delta: f64,
756        #[serde(default, skip_serializing_if = "Vec::is_empty")]
757        pub regressed_extensions: Vec<ExtensionRegression>,
758    }
759
760    impl ConformanceRegression {
761        #[must_use]
762        pub fn has_regression(&self) -> bool {
763            const EPS: f64 = 1e-12;
764            self.pass_rate_delta < -EPS || !self.regressed_extensions.is_empty()
765        }
766    }
767
768    fn tier_key(tier: Option<u32>) -> String {
769        tier.map_or_else(|| "tier_unknown".to_string(), |tier| format!("tier{tier}"))
770    }
771
772    fn now_timestamp_string() -> String {
773        Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
774    }
775
776    /// Build a report from per-extension results.
777    ///
778    /// - `timestamp` defaults to `Utc::now()` if `None`.
779    /// - Results are sorted by (tier, id) for deterministic output.
780    #[must_use]
781    pub fn generate_report(
782        run_id: impl Into<String>,
783        timestamp: Option<String>,
784        mut results: Vec<ExtensionConformanceResult>,
785    ) -> ConformanceReport {
786        results.sort_by(|left, right| {
787            let left_tier = left.tier.unwrap_or(u32::MAX);
788            let right_tier = right.tier.unwrap_or(u32::MAX);
789            (left_tier, &left.id).cmp(&(right_tier, &right.id))
790        });
791
792        let total = results.len() as u64;
793        let passed = results
794            .iter()
795            .filter(|r| r.status == ConformanceStatus::Pass)
796            .count() as u64;
797        let failed = results
798            .iter()
799            .filter(|r| r.status == ConformanceStatus::Fail)
800            .count() as u64;
801        let skipped = results
802            .iter()
803            .filter(|r| r.status == ConformanceStatus::Skip)
804            .count() as u64;
805        let errors = results
806            .iter()
807            .filter(|r| r.status == ConformanceStatus::Error)
808            .count() as u64;
809
810        let pass_rate = ratio(passed, total);
811
812        let mut by_tier: BTreeMap<String, Vec<ExtensionConformanceResult>> = BTreeMap::new();
813        for result in &results {
814            by_tier
815                .entry(tier_key(result.tier))
816                .or_default()
817                .push(result.clone());
818        }
819
820        let by_tier = by_tier
821            .into_iter()
822            .map(|(key, items)| (key, TierSummary::from_results(&items)))
823            .collect::<BTreeMap<_, _>>();
824
825        ConformanceReport {
826            run_id: run_id.into(),
827            timestamp: timestamp.unwrap_or_else(now_timestamp_string),
828            summary: ConformanceSummary {
829                total,
830                passed,
831                failed,
832                skipped,
833                errors,
834                pass_rate,
835                by_tier,
836            },
837            extensions: results,
838        }
839    }
840
841    /// Compute regression signals between a previous and current report.
842    ///
843    /// Semantics:
844    /// - Pass-rate deltas are computed over the *previous* extension set only, so that
845    ///   newly-added extensions do not count as regressions.
846    /// - An extension regresses if it was `PASS` previously and is now non-`PASS` (or
847    ///   missing).
848    #[must_use]
849    pub fn compute_regression(
850        previous: &ConformanceReport,
851        current: &ConformanceReport,
852    ) -> ConformanceRegression {
853        let compared_total = previous.extensions.len() as u64;
854        let previous_passed = previous
855            .extensions
856            .iter()
857            .filter(|r| r.status == ConformanceStatus::Pass)
858            .count() as u64;
859
860        let current_by_id = current
861            .extensions
862            .iter()
863            .map(|r| (r.id.as_str(), r.status))
864            .collect::<BTreeMap<_, _>>();
865
866        let mut current_passed = 0u64;
867        let mut regressed_extensions = Vec::new();
868        for result in &previous.extensions {
869            let current_status = current_by_id.get(result.id.as_str()).copied();
870            if matches!(current_status, Some(ConformanceStatus::Pass)) {
871                current_passed = current_passed.saturating_add(1);
872            }
873
874            if result.status == ConformanceStatus::Pass
875                && !matches!(current_status, Some(ConformanceStatus::Pass))
876            {
877                regressed_extensions.push(ExtensionRegression {
878                    id: result.id.clone(),
879                    previous: result.status,
880                    current: current_status,
881                });
882            }
883        }
884
885        let previous_pass_rate = ratio(previous_passed, compared_total);
886        let current_pass_rate = ratio(current_passed, compared_total);
887        let pass_rate_delta = current_pass_rate - previous_pass_rate;
888
889        ConformanceRegression {
890            compared_total,
891            previous_passed,
892            current_passed,
893            previous_pass_rate,
894            current_pass_rate,
895            pass_rate_delta,
896            regressed_extensions,
897        }
898    }
899
900    impl ConformanceReport {
901        /// Render a human-readable Markdown report.
902        #[must_use]
903        pub fn render_markdown(&self) -> String {
904            let mut out = String::new();
905            let pass_rate_pct = self.summary.pass_rate * 100.0;
906            let _ = writeln!(out, "# Extension Conformance Report");
907            let _ = writeln!(out, "Generated: {}", self.timestamp);
908            let _ = writeln!(out, "Run ID: {}", self.run_id);
909            let _ = writeln!(out);
910            let _ = writeln!(
911                out,
912                "Pass Rate: {:.1}% ({}/{})",
913                pass_rate_pct, self.summary.passed, self.summary.total
914            );
915            let _ = writeln!(out);
916            let _ = writeln!(out, "## Summary");
917            let _ = writeln!(out, "- Total: {}", self.summary.total);
918            let _ = writeln!(out, "- Passed: {}", self.summary.passed);
919            let _ = writeln!(out, "- Failed: {}", self.summary.failed);
920            let _ = writeln!(out, "- Skipped: {}", self.summary.skipped);
921            let _ = writeln!(out, "- Errors: {}", self.summary.errors);
922            let _ = writeln!(out);
923
924            let _ = writeln!(out, "## By Tier");
925            for (tier, summary) in &self.summary.by_tier {
926                let tier_label = match tier.strip_prefix("tier") {
927                    Some(num) if !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) => {
928                        format!("Tier {num}")
929                    }
930                    _ => tier.clone(),
931                };
932                let _ = writeln!(
933                    out,
934                    "### {tier_label}: {:.1}% ({}/{})",
935                    summary.pass_rate * 100.0,
936                    summary.passed,
937                    summary.total
938                );
939                let _ = writeln!(out);
940                let _ = writeln!(out, "| Extension | Status | TS Time | Rust Time | Notes |");
941                let _ = writeln!(out, "|---|---|---:|---:|---|");
942                for result in self.extensions.iter().filter(|r| &tier_key(r.tier) == tier) {
943                    let ts_time = result
944                        .ts_time_ms
945                        .map_or_else(String::new, |v| format!("{v}ms"));
946                    let rust_time = result
947                        .rust_time_ms
948                        .map_or_else(String::new, |v| format!("{v}ms"));
949                    let notes = result.notes.as_deref().unwrap_or("");
950                    let _ = writeln!(
951                        out,
952                        "| {} | {} | {} | {} | {} |",
953                        result.id,
954                        result.status.as_upper_str(),
955                        ts_time,
956                        rust_time,
957                        notes
958                    );
959                }
960                let _ = writeln!(out);
961            }
962
963            let failures = self
964                .extensions
965                .iter()
966                .filter(|r| matches!(r.status, ConformanceStatus::Fail | ConformanceStatus::Error))
967                .collect::<Vec<_>>();
968
969            let _ = writeln!(out, "## Failures");
970            if failures.is_empty() {
971                let _ = writeln!(out, "(none)");
972                return out;
973            }
974
975            for failure in failures {
976                let tier = failure
977                    .tier
978                    .map_or_else(|| "unknown".to_string(), |v| v.to_string());
979                let _ = writeln!(out, "### {} (Tier {})", failure.id, tier);
980                if let Some(notes) = failure.notes.as_deref().filter(|v| !v.is_empty()) {
981                    let _ = writeln!(out, "**Notes**: {notes}");
982                }
983                if failure.diffs.is_empty() {
984                    let _ = writeln!(out, "- (no diff details)");
985                } else {
986                    for diff in &failure.diffs {
987                        let _ = writeln!(out, "- `{}`: {}", diff.path, diff.message);
988                    }
989                }
990                let _ = writeln!(out);
991            }
992
993            out
994        }
995    }
996}
997
998// ============================================================================
999// Snapshot Protocol (bd-1pqf)
1000// ============================================================================
1001
1002/// Snapshot protocol for extension conformance artifacts.
1003///
1004/// This module codifies the canonical folder layout, naming conventions,
1005/// metadata requirements, and integrity checks that all extension artifacts
1006/// must satisfy.  Acquisition tasks (templates, GitHub releases, npm tarballs)
1007/// **MUST** use this protocol so that the conformance test infrastructure can
1008/// discover and verify artifacts automatically.
1009///
1010/// # Folder layout
1011///
1012/// ```text
1013/// tests/ext_conformance/artifacts/
1014/// ├── <official-extension-id>/         ← top-level = official-pi-mono
1015/// ├── community/<extension-id>/        ← community tier
1016/// ├── npm/<extension-id>/              ← npm-registry tier
1017/// ├── third-party/<extension-id>/      ← third-party-github tier
1018/// ├── agents-mikeastock/<id>/          ← agents-mikeastock tier
1019/// ├── templates/<id>/                  ← templates tier (future)
1020/// ├── CATALOG.json                     ← quick-reference metadata
1021/// ├── SHA256SUMS.txt                   ← per-file checksums
1022/// └── (test fixture dirs excluded)
1023/// ```
1024///
1025/// # Naming conventions
1026///
1027/// - Extension IDs: lowercase ASCII, digits, hyphens.  No spaces, underscores
1028///   or uppercase.  Forward slashes allowed only for tier-scoped paths
1029///   (e.g. `community/my-ext`).
1030/// - Directory name == extension ID (within its tier prefix).
1031///
1032/// # Integrity
1033///
1034/// Every artifact must have a deterministic SHA-256 directory digest computed
1035/// by `digest_artifact_dir`.  This digest is stored in both
1036/// `extension-master-catalog.json` and `extension-artifact-provenance.json`.
1037pub mod snapshot {
1038    use serde::{Deserialize, Serialize};
1039    use sha2::{Digest, Sha256};
1040    use std::fmt::Write as _;
1041    use std::io;
1042    use std::path::{Path, PathBuf};
1043
1044    // === Layout constants ===
1045
1046    /// Root directory for all extension artifacts (relative to repo root).
1047    pub const ARTIFACT_ROOT: &str = "tests/ext_conformance/artifacts";
1048
1049    /// Reserved tier-scoped subdirectories.  Extensions placed under these
1050    /// directories use `<tier>/<extension-id>/` layout.
1051    pub const TIER_SCOPED_DIRS: &[&str] = &[
1052        "community",
1053        "npm",
1054        "third-party",
1055        "agents-mikeastock",
1056        "templates",
1057    ];
1058
1059    /// Directories excluded from conformance testing entirely.
1060    pub const EXCLUDED_DIRS: &[&str] = &[
1061        "plugins-official",
1062        "plugins-community",
1063        "plugins-ariff",
1064        "agents-wshobson",
1065        "templates-davila7",
1066    ];
1067
1068    /// Non-extension directories that contain test fixtures, not artifacts.
1069    pub const FIXTURE_DIRS: &[&str] = &[
1070        "base_fixtures",
1071        "diff",
1072        "files",
1073        "negative-denied-caps",
1074        "reports",
1075    ];
1076
1077    // === Source tier ===
1078
1079    /// Classification of where an extension artifact was obtained.
1080    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1081    #[serde(rename_all = "kebab-case")]
1082    pub enum SourceTier {
1083        OfficialPiMono,
1084        Community,
1085        NpmRegistry,
1086        ThirdPartyGithub,
1087        AgentsMikeastock,
1088        Templates,
1089    }
1090
1091    impl SourceTier {
1092        /// Directory prefix for this tier (`None` means top-level / official).
1093        #[must_use]
1094        pub const fn directory_prefix(self) -> Option<&'static str> {
1095            match self {
1096                Self::OfficialPiMono => None,
1097                Self::Community => Some("community"),
1098                Self::NpmRegistry => Some("npm"),
1099                Self::ThirdPartyGithub => Some("third-party"),
1100                Self::AgentsMikeastock => Some("agents-mikeastock"),
1101                Self::Templates => Some("templates"),
1102            }
1103        }
1104
1105        /// Determine tier from a directory path relative to the artifact root.
1106        #[must_use]
1107        pub fn from_directory(dir: &str) -> Self {
1108            if dir.starts_with("community/") {
1109                Self::Community
1110            } else if dir.starts_with("npm/") {
1111                Self::NpmRegistry
1112            } else if dir.starts_with("third-party/") {
1113                Self::ThirdPartyGithub
1114            } else if dir.starts_with("agents-mikeastock/") {
1115                Self::AgentsMikeastock
1116            } else if dir.starts_with("templates/") {
1117                Self::Templates
1118            } else {
1119                Self::OfficialPiMono
1120            }
1121        }
1122    }
1123
1124    // === Artifact source ===
1125
1126    /// Where an artifact was obtained from.  Stored in the provenance manifest.
1127    #[derive(Debug, Clone, Serialize, Deserialize)]
1128    #[serde(rename_all = "snake_case", tag = "type")]
1129    pub enum ArtifactSource {
1130        /// Cloned from a git repository.
1131        Git {
1132            repo: String,
1133            #[serde(default, skip_serializing_if = "Option::is_none")]
1134            path: Option<String>,
1135            #[serde(default, skip_serializing_if = "Option::is_none")]
1136            commit: Option<String>,
1137        },
1138        /// Downloaded from the npm registry.
1139        Npm {
1140            package: String,
1141            version: String,
1142            #[serde(default, skip_serializing_if = "Option::is_none")]
1143            url: Option<String>,
1144        },
1145        /// Downloaded from a direct URL.
1146        Url { url: String },
1147    }
1148
1149    // === Artifact spec ===
1150
1151    /// Specification for a new artifact to be added to the conformance corpus.
1152    ///
1153    /// Acquisition tasks construct an `ArtifactSpec`, validate it with
1154    /// [`validate_artifact_spec`], write the files, then compute and record
1155    /// the directory digest.
1156    #[derive(Debug, Clone, Serialize, Deserialize)]
1157    pub struct ArtifactSpec {
1158        /// Unique extension ID (lowercase, hyphens, digits).
1159        pub id: String,
1160        /// Relative directory under the artifact root.
1161        pub directory: String,
1162        /// Human-readable name.
1163        pub name: String,
1164        /// Source tier classification.
1165        pub source_tier: SourceTier,
1166        /// License identifier (SPDX short form, or `"UNKNOWN"`).
1167        pub license: String,
1168        /// Where the artifact was obtained from.
1169        pub source: ArtifactSource,
1170    }
1171
1172    // === Naming validation ===
1173
1174    /// Validate that an extension ID follows naming conventions.
1175    ///
1176    /// Rules:
1177    /// - Non-empty
1178    /// - Lowercase ASCII letters, digits, hyphens, forward slashes
1179    /// - Must not start or end with a hyphen
1180    pub fn validate_id(id: &str) -> Result<(), String> {
1181        if id.is_empty() {
1182            return Err("extension ID must not be empty".into());
1183        }
1184        if id != id.to_ascii_lowercase() {
1185            return Err(format!("extension ID must be lowercase: {id:?}"));
1186        }
1187        for ch in id.chars() {
1188            if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '-' && ch != '/' {
1189                return Err(format!(
1190                    "extension ID contains invalid character {ch:?}: {id:?}"
1191                ));
1192            }
1193        }
1194        if id.starts_with('-') || id.ends_with('-') {
1195            return Err(format!(
1196                "extension ID must not start or end with hyphen: {id:?}"
1197            ));
1198        }
1199        Ok(())
1200    }
1201
1202    /// Validate that a directory follows layout conventions for the given tier.
1203    pub fn validate_directory(dir: &str, tier: SourceTier) -> Result<(), String> {
1204        if dir.is_empty() {
1205            return Err("directory must not be empty".into());
1206        }
1207        match tier.directory_prefix() {
1208            Some(prefix) => {
1209                if !dir.starts_with(&format!("{prefix}/")) {
1210                    return Err(format!(
1211                        "directory {dir:?} must start with \"{prefix}/\" for tier {tier:?}"
1212                    ));
1213                }
1214            }
1215            None => {
1216                for scoped in TIER_SCOPED_DIRS {
1217                    if dir.starts_with(&format!("{scoped}/")) {
1218                        return Err(format!(
1219                            "official extension directory {dir:?} must not be under \
1220                             tier-scoped dir \"{scoped}/\""
1221                        ));
1222                    }
1223                }
1224            }
1225        }
1226        Ok(())
1227    }
1228
1229    // === Integrity ===
1230
1231    fn hex_lower(bytes: &[u8]) -> String {
1232        let mut output = String::with_capacity(bytes.len() * 2);
1233        for byte in bytes {
1234            let _ = write!(&mut output, "{byte:02x}");
1235        }
1236        output
1237    }
1238
1239    fn collect_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
1240        for entry in std::fs::read_dir(dir)? {
1241            let entry = entry?;
1242            let ft = entry.file_type()?;
1243            let path = entry.path();
1244            if ft.is_dir() {
1245                collect_files_recursive(&path, files)?;
1246            } else if ft.is_file() {
1247                files.push(path);
1248            }
1249        }
1250        Ok(())
1251    }
1252
1253    fn relative_posix(root: &Path, path: &Path) -> String {
1254        let rel = path.strip_prefix(root).unwrap_or(path);
1255        rel.components()
1256            .map(|c| c.as_os_str().to_string_lossy())
1257            .collect::<Vec<_>>()
1258            .join("/")
1259    }
1260
1261    /// Compute a deterministic SHA-256 digest of an artifact directory.
1262    ///
1263    /// Algorithm:
1264    /// 1. Recursively collect all regular files.
1265    /// 2. Sort by POSIX relative path (ensures cross-platform determinism).
1266    /// 3. For each file, feed `"file\0"`, relative path, `"\0"`, file bytes,
1267    ///    `"\0"` into the hasher.
1268    /// 4. Return lowercase hex digest (64 chars).
1269    pub fn digest_artifact_dir(dir: &Path) -> io::Result<String> {
1270        let mut files = Vec::new();
1271        collect_files_recursive(dir, &mut files)?;
1272        files.sort_by_key(|p| relative_posix(dir, p));
1273
1274        let mut hasher = Sha256::new();
1275        for path in &files {
1276            let rel = relative_posix(dir, path);
1277            hasher.update(b"file\0");
1278            hasher.update(rel.as_bytes());
1279            hasher.update(b"\0");
1280            // Strip \r so CRLF (Windows autocrlf) hashes the same as LF (Unix)
1281            let content: Vec<u8> = std::fs::read(path)?
1282                .into_iter()
1283                .filter(|&b| b != b'\r')
1284                .collect();
1285            hasher.update(&content);
1286            hasher.update(b"\0");
1287        }
1288        Ok(hex_lower(&hasher.finalize()))
1289    }
1290
1291    /// Verify an artifact directory's checksum matches an expected value.
1292    ///
1293    /// Returns `Ok(Ok(()))` if the checksum matches, `Ok(Err(msg))` on
1294    /// mismatch, or `Err(io_err)` if the directory cannot be read.
1295    pub fn verify_integrity(dir: &Path, expected_sha256: &str) -> io::Result<Result<(), String>> {
1296        let actual = digest_artifact_dir(dir)?;
1297        if actual == expected_sha256 {
1298            Ok(Ok(()))
1299        } else {
1300            Ok(Err(format!(
1301                "checksum mismatch for {}: expected {expected_sha256}, got {actual}",
1302                dir.display()
1303            )))
1304        }
1305    }
1306
1307    /// Validate a new artifact spec against all protocol rules.
1308    ///
1309    /// Returns an empty vec on success, or a list of human-readable errors.
1310    #[must_use]
1311    pub fn validate_artifact_spec(spec: &ArtifactSpec) -> Vec<String> {
1312        let mut errors = Vec::new();
1313
1314        if let Err(e) = validate_id(&spec.id) {
1315            errors.push(e);
1316        }
1317        if let Err(e) = validate_directory(&spec.directory, spec.source_tier) {
1318            errors.push(e);
1319        }
1320        if spec.name.is_empty() {
1321            errors.push("name must not be empty".into());
1322        }
1323        if spec.license.is_empty() {
1324            errors.push("license must not be empty (use \"UNKNOWN\" if unknown)".into());
1325        }
1326
1327        match &spec.source {
1328            ArtifactSource::Git { repo, .. } => {
1329                if repo.is_empty() {
1330                    errors.push("git source must have non-empty repo URL".into());
1331                }
1332            }
1333            ArtifactSource::Npm {
1334                package, version, ..
1335            } => {
1336                if package.is_empty() {
1337                    errors.push("npm source must have non-empty package name".into());
1338                }
1339                if version.is_empty() {
1340                    errors.push("npm source must have non-empty version".into());
1341                }
1342            }
1343            ArtifactSource::Url { url } => {
1344                if url.is_empty() {
1345                    errors.push("url source must have non-empty URL".into());
1346                }
1347            }
1348        }
1349
1350        errors
1351    }
1352
1353    /// Check whether a directory name is reserved (excluded, fixture, or
1354    /// tier-scoped) and therefore not a direct extension directory.
1355    #[must_use]
1356    pub fn is_reserved_dir(name: &str) -> bool {
1357        EXCLUDED_DIRS.contains(&name)
1358            || FIXTURE_DIRS.contains(&name)
1359            || TIER_SCOPED_DIRS.contains(&name)
1360    }
1361}
1362
1363// ============================================================================
1364// Normalization Contract (bd-k5q5.1.1)
1365// ============================================================================
1366
1367/// Canonical event schema and normalization contract for conformance testing.
1368///
1369/// This module formally defines which fields in conformance events are
1370/// **semantic** (must match across runtimes), **transport** (non-deterministic
1371/// noise that must be normalized before comparison), or **derived**
1372/// (computed from other fields and ignored during comparison).
1373///
1374/// # Schema version
1375///
1376/// The schema is versioned so that changes to normalization rules can be
1377/// tracked.  Fixtures and baselines record which schema version they were
1378/// generated against.
1379///
1380/// # Usage
1381///
1382/// ```rust,ignore
1383/// use pi::conformance::normalization::*;
1384///
1385/// let contract = NormalizationContract::default();
1386/// let ctx = NormalizationContext::from_cwd(std::path::Path::new("/tmp"));
1387/// let mut event: serde_json::Value = serde_json::from_str(raw_line)?;
1388/// contract.normalize(&mut event, &ctx);
1389/// ```
1390pub mod normalization {
1391    use regex::Regex;
1392    use serde::{Deserialize, Serialize};
1393    use serde_json::Value;
1394    use std::path::{Path, PathBuf};
1395    use std::sync::OnceLock;
1396
1397    // ── Schema version ─────────────────────────────────────────────────
1398
1399    /// Current schema version.  Bump when normalization rules change.
1400    pub const SCHEMA_VERSION: &str = "1.0.0";
1401
1402    // ── Placeholder constants ──────────────────────────────────────────
1403    //
1404    // Canonical placeholder strings used to replace transport-noise values.
1405    // These were previously scattered in `tests/ext_conformance.rs`; now
1406    // they live in the library so both test code and CI tooling share one
1407    // source of truth.
1408
1409    pub const PLACEHOLDER_TIMESTAMP: &str = "<TIMESTAMP>";
1410    pub const PLACEHOLDER_HOST: &str = "<HOST>";
1411    pub const PLACEHOLDER_SESSION_ID: &str = "<SESSION_ID>";
1412    pub const PLACEHOLDER_RUN_ID: &str = "<RUN_ID>";
1413    pub const PLACEHOLDER_ARTIFACT_ID: &str = "<ARTIFACT_ID>";
1414    pub const PLACEHOLDER_TRACE_ID: &str = "<TRACE_ID>";
1415    pub const PLACEHOLDER_SPAN_ID: &str = "<SPAN_ID>";
1416    pub const PLACEHOLDER_UUID: &str = "<UUID>";
1417    pub const PLACEHOLDER_PI_MONO_ROOT: &str = "<PI_MONO_ROOT>";
1418    pub const PLACEHOLDER_PROJECT_ROOT: &str = "<PROJECT_ROOT>";
1419    pub const PLACEHOLDER_PORT: &str = "<PORT>";
1420    pub const PLACEHOLDER_PID: &str = "<PID>";
1421
1422    // ── Field classification ───────────────────────────────────────────
1423
1424    /// How a conformance event field is treated during comparison.
1425    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1426    #[serde(rename_all = "snake_case")]
1427    pub enum FieldClassification {
1428        /// Field carries meaning that MUST match between TS and Rust runtimes.
1429        ///
1430        /// Examples: `extension_id`, `event`, `level`, `schema`,
1431        /// registration contents, hostcall payloads.
1432        Semantic,
1433
1434        /// Field is non-deterministic transport noise that MUST be normalized
1435        /// (replaced with a placeholder) before comparison.
1436        ///
1437        /// Examples: timestamps, PIDs, session IDs, UUIDs, absolute paths,
1438        /// ANSI escape sequences, hostnames, port numbers.
1439        Transport,
1440
1441        /// Field is derived from other fields and is skipped during comparison.
1442        ///
1443        /// Examples: computed durations, cache keys, internal sequence numbers.
1444        Derived,
1445    }
1446
1447    /// Describes a single field's normalization rule.
1448    #[derive(Debug, Clone, Serialize, Deserialize)]
1449    pub struct FieldRule {
1450        /// JSON path pattern (dot-separated, `*` for any key at that level).
1451        ///
1452        /// Examples: `"ts"`, `"correlation.session_id"`, `"source.pid"`,
1453        /// `"*.timestamp"` (matches `timestamp` at any depth).
1454        pub path_pattern: String,
1455
1456        /// Classification for this field.
1457        pub classification: FieldClassification,
1458
1459        /// Placeholder to substitute for transport fields.
1460        ///
1461        /// Only meaningful when `classification == Transport`.
1462        #[serde(default, skip_serializing_if = "Option::is_none")]
1463        pub placeholder: Option<String>,
1464    }
1465
1466    /// Describes a string-level rewrite applied to all string values.
1467    #[derive(Debug, Clone)]
1468    pub struct StringRewriteRule {
1469        /// Human-readable name for this rule.
1470        pub name: &'static str,
1471        /// Regex pattern to match within string values.
1472        pub regex: &'static OnceLock<Regex>,
1473        /// Replacement string (may contain `$1` etc. for captures).
1474        pub replacement: &'static str,
1475    }
1476
1477    // ── Normalization context ──────────────────────────────────────────
1478
1479    /// Environment-specific values needed for path canonicalization.
1480    ///
1481    /// Promoted from `tests/ext_conformance.rs` to the library so that both
1482    /// test code, CI tooling, and future replay infrastructure share one
1483    /// implementation.
1484    #[derive(Debug, Clone)]
1485    pub struct NormalizationContext {
1486        /// Absolute path to the pi_agent_rust repository root.
1487        pub project_root: String,
1488        /// Absolute path to `legacy_pi_mono_code/pi-mono`.
1489        pub pi_mono_root: String,
1490        /// Working directory used during the conformance run.
1491        pub cwd: String,
1492    }
1493
1494    impl NormalizationContext {
1495        /// Build from a working directory, auto-detecting project roots.
1496        #[must_use]
1497        pub fn from_cwd(cwd: &Path) -> Self {
1498            let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1499                .canonicalize()
1500                .unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")))
1501                .display()
1502                .to_string();
1503            let pi_mono_root = PathBuf::from(&project_root)
1504                .join("legacy_pi_mono_code")
1505                .join("pi-mono")
1506                .canonicalize()
1507                .unwrap_or_else(|_| {
1508                    PathBuf::from(&project_root)
1509                        .join("legacy_pi_mono_code")
1510                        .join("pi-mono")
1511                })
1512                .display()
1513                .to_string();
1514            let cwd = cwd
1515                .canonicalize()
1516                .unwrap_or_else(|_| cwd.to_path_buf())
1517                .display()
1518                .to_string();
1519            Self {
1520                project_root,
1521                pi_mono_root,
1522                cwd,
1523            }
1524        }
1525
1526        /// Build with explicit paths (for deterministic testing).
1527        #[must_use]
1528        pub const fn new(project_root: String, pi_mono_root: String, cwd: String) -> Self {
1529            Self {
1530                project_root,
1531                pi_mono_root,
1532                cwd,
1533            }
1534        }
1535    }
1536
1537    // ── Lazy-initialized regexes ───────────────────────────────────────
1538
1539    static ANSI_REGEX: OnceLock<Regex> = OnceLock::new();
1540    static RUN_ID_REGEX: OnceLock<Regex> = OnceLock::new();
1541    static UUID_REGEX: OnceLock<Regex> = OnceLock::new();
1542    static OPENAI_BASE_REGEX: OnceLock<Regex> = OnceLock::new();
1543
1544    fn ansi_regex() -> &'static Regex {
1545        ANSI_REGEX.get_or_init(|| Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").expect("ansi regex"))
1546    }
1547
1548    fn run_id_regex() -> &'static Regex {
1549        RUN_ID_REGEX.get_or_init(|| Regex::new(r"\brun-[0-9a-fA-F-]{36}\b").expect("run id regex"))
1550    }
1551
1552    fn uuid_regex() -> &'static Regex {
1553        UUID_REGEX.get_or_init(|| {
1554            Regex::new(
1555                r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
1556            )
1557            .expect("uuid regex")
1558        })
1559    }
1560
1561    fn openai_base_regex() -> &'static Regex {
1562        OPENAI_BASE_REGEX
1563            .get_or_init(|| Regex::new(r"http://127\.0\.0\.1:\d+/v1").expect("openai base regex"))
1564    }
1565
1566    // ── Key-name classification ────────────────────────────────────────
1567    //
1568    // The canonical set of JSON key names whose *presence* determines
1569    // transport classification.  This replaces the ad-hoc `matches!` chains
1570    // that were scattered in the test file.
1571
1572    /// Key names that indicate a timestamp (string → placeholder, number → 0).
1573    const TIMESTAMP_KEYS: &[&str] = &[
1574        "timestamp",
1575        "started_at",
1576        "finished_at",
1577        "created_at",
1578        "createdAt",
1579        "ts",
1580    ];
1581
1582    /// Key names for string-valued transport IDs.
1583    const TRANSPORT_ID_KEYS: &[(&str, &str)] = &[
1584        ("session_id", PLACEHOLDER_SESSION_ID),
1585        ("sessionId", PLACEHOLDER_SESSION_ID),
1586        ("run_id", PLACEHOLDER_RUN_ID),
1587        ("runId", PLACEHOLDER_RUN_ID),
1588        ("artifact_id", PLACEHOLDER_ARTIFACT_ID),
1589        ("artifactId", PLACEHOLDER_ARTIFACT_ID),
1590        ("trace_id", PLACEHOLDER_TRACE_ID),
1591        ("traceId", PLACEHOLDER_TRACE_ID),
1592        ("span_id", PLACEHOLDER_SPAN_ID),
1593        ("spanId", PLACEHOLDER_SPAN_ID),
1594    ];
1595
1596    /// Key names replaced unconditionally with a fixed placeholder.
1597    const FIXED_PLACEHOLDER_KEYS: &[(&str, &str)] = &[
1598        ("cwd", PLACEHOLDER_PI_MONO_ROOT),
1599        ("host", PLACEHOLDER_HOST),
1600    ];
1601
1602    /// Numeric key names that are zeroed out.
1603    const ZEROED_NUMBER_KEYS: &[&str] = &["pid"];
1604
1605    // ── Normalization contract ──────────────────────────────────────────
1606
1607    /// The canonical normalization contract.
1608    ///
1609    /// Encapsulates all rules needed to transform a raw conformance event
1610    /// into its normalized form suitable for comparison.
1611    #[derive(Debug, Clone, Serialize, Deserialize)]
1612    pub struct NormalizationContract {
1613        /// Schema version this contract was defined against.
1614        pub schema_version: String,
1615        /// Field-level rules (by key name).
1616        pub field_rules: Vec<FieldRule>,
1617    }
1618
1619    impl Default for NormalizationContract {
1620        fn default() -> Self {
1621            let mut field_rules = Vec::new();
1622
1623            // Timestamp fields → Transport
1624            for &key in TIMESTAMP_KEYS {
1625                field_rules.push(FieldRule {
1626                    path_pattern: format!("*.{key}"),
1627                    classification: FieldClassification::Transport,
1628                    placeholder: Some(PLACEHOLDER_TIMESTAMP.to_string()),
1629                });
1630            }
1631
1632            // Transport ID fields
1633            for &(key, placeholder) in TRANSPORT_ID_KEYS {
1634                field_rules.push(FieldRule {
1635                    path_pattern: format!("*.{key}"),
1636                    classification: FieldClassification::Transport,
1637                    placeholder: Some(placeholder.to_string()),
1638                });
1639            }
1640
1641            // Fixed placeholder fields
1642            for &(key, placeholder) in FIXED_PLACEHOLDER_KEYS {
1643                field_rules.push(FieldRule {
1644                    path_pattern: format!("*.{key}"),
1645                    classification: FieldClassification::Transport,
1646                    placeholder: Some(placeholder.to_string()),
1647                });
1648            }
1649
1650            // Numeric transport fields
1651            for &key in ZEROED_NUMBER_KEYS {
1652                field_rules.push(FieldRule {
1653                    path_pattern: format!("*.{key}"),
1654                    classification: FieldClassification::Transport,
1655                    placeholder: Some("0".to_string()),
1656                });
1657            }
1658
1659            // Semantic fields (documented for downstream consumers)
1660            for key in &[
1661                "schema",
1662                "level",
1663                "event",
1664                "message",
1665                "extension_id",
1666                "data",
1667            ] {
1668                field_rules.push(FieldRule {
1669                    path_pattern: (*key).to_string(),
1670                    classification: FieldClassification::Semantic,
1671                    placeholder: None,
1672                });
1673            }
1674
1675            Self {
1676                schema_version: SCHEMA_VERSION.to_string(),
1677                field_rules,
1678            }
1679        }
1680    }
1681
1682    impl NormalizationContract {
1683        /// Normalize a conformance event in-place.
1684        ///
1685        /// Applies all rules from this contract: key-based field replacement,
1686        /// path canonicalization, ANSI stripping, UUID/run-ID/port rewriting.
1687        pub fn normalize(&self, value: &mut Value, ctx: &NormalizationContext) {
1688            normalize_value(value, None, ctx);
1689        }
1690
1691        /// Normalize and canonicalize (sort keys) for stable comparison.
1692        #[must_use]
1693        pub fn normalize_and_canonicalize(
1694            &self,
1695            value: Value,
1696            ctx: &NormalizationContext,
1697        ) -> Value {
1698            let mut v = value;
1699            self.normalize(&mut v, ctx);
1700            canonicalize_json_keys(&v)
1701        }
1702    }
1703
1704    // ── Core normalization functions ────────────────────────────────────
1705    //
1706    // Promoted from `tests/ext_conformance.rs` to library code.
1707
1708    /// Normalize a JSON value in-place according to the canonical rules.
1709    ///
1710    /// - Timestamp keys (string or number) → placeholder / zero
1711    /// - Transport ID keys → placeholder
1712    /// - Fixed keys (cwd, host) → placeholder
1713    /// - Numeric transport keys (pid) → zero
1714    /// - All strings: ANSI stripping, path canonicalization, UUID/run-ID
1715    ///   rewriting
1716    pub fn normalize_value(value: &mut Value, key: Option<&str>, ctx: &NormalizationContext) {
1717        match value {
1718            Value::Null | Value::Bool(_) => {}
1719            Value::String(s) => {
1720                // Key-based transport replacement (string timestamps)
1721                if matches_any_key(key, TIMESTAMP_KEYS) {
1722                    *s = PLACEHOLDER_TIMESTAMP.to_string();
1723                    return;
1724                }
1725                // Transport ID fields
1726                if let Some(placeholder) = transport_id_placeholder(key) {
1727                    *s = placeholder.to_string();
1728                    return;
1729                }
1730                // Fixed placeholder fields
1731                if let Some(placeholder) = fixed_placeholder(key) {
1732                    *s = placeholder.to_string();
1733                    return;
1734                }
1735                // General string normalization
1736                *s = normalize_string(s, ctx);
1737            }
1738            Value::Array(items) => {
1739                for item in items {
1740                    normalize_value(item, None, ctx);
1741                }
1742            }
1743            Value::Object(map) => {
1744                for (k, item) in map.iter_mut() {
1745                    normalize_value(item, Some(k.as_str()), ctx);
1746                }
1747                // Canonicalize UI operation method names so that
1748                // `setStatus`/`set_status`/`status` all compare equal.
1749                canonicalize_ui_method(map);
1750            }
1751            Value::Number(_) => {
1752                if matches_any_key(key, TIMESTAMP_KEYS) || matches_any_key(key, ZEROED_NUMBER_KEYS)
1753                {
1754                    *value = Value::Number(0.into());
1755                }
1756            }
1757        }
1758    }
1759
1760    /// Normalize a string value: strip ANSI, rewrite paths, replace UUIDs/run-IDs/ports.
1761    #[must_use]
1762    pub fn normalize_string(input: &str, ctx: &NormalizationContext) -> String {
1763        // 1) Strip ANSI escape sequences
1764        let without_ansi = ansi_regex().replace_all(input, "");
1765
1766        // 2) Path canonicalization (order matters: most-specific first)
1767        let mut out = without_ansi.to_string();
1768        out = replace_path_variants(&out, &ctx.cwd, PLACEHOLDER_PI_MONO_ROOT);
1769        out = replace_path_variants(&out, &ctx.pi_mono_root, PLACEHOLDER_PI_MONO_ROOT);
1770        out = replace_path_variants(&out, &ctx.project_root, PLACEHOLDER_PROJECT_ROOT);
1771
1772        // 3) Run-ID rewriting
1773        out = run_id_regex()
1774            .replace_all(&out, PLACEHOLDER_RUN_ID)
1775            .into_owned();
1776
1777        // 4) OpenAI base URL port normalization
1778        out = openai_base_regex()
1779            .replace_all(&out, format!("http://127.0.0.1:{PLACEHOLDER_PORT}/v1"))
1780            .into_owned();
1781
1782        // 5) UUID rewriting
1783        out = uuid_regex()
1784            .replace_all(&out, PLACEHOLDER_UUID)
1785            .into_owned();
1786
1787        out
1788    }
1789
1790    /// Sort JSON object keys recursively for stable serialization.
1791    #[must_use]
1792    pub fn canonicalize_json_keys(value: &Value) -> Value {
1793        match value {
1794            Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => value.clone(),
1795            Value::Array(items) => Value::Array(items.iter().map(canonicalize_json_keys).collect()),
1796            Value::Object(map) => {
1797                let mut keys = map.keys().cloned().collect::<Vec<_>>();
1798                keys.sort();
1799                let mut out = serde_json::Map::new();
1800                for key in keys {
1801                    if let Some(v) = map.get(&key) {
1802                        out.insert(key, canonicalize_json_keys(v));
1803                    }
1804                }
1805                Value::Object(out)
1806            }
1807        }
1808    }
1809
1810    /// Replace path and its backslash variant with a placeholder.
1811    fn replace_path_variants(input: &str, path: &str, placeholder: &str) -> String {
1812        if path.is_empty() {
1813            return input.to_string();
1814        }
1815        let mut out = input.replace(path, placeholder);
1816        let path_backslashes = path.replace('/', "\\");
1817        if path_backslashes != path {
1818            out = out.replace(&path_backslashes, placeholder);
1819        }
1820        out
1821    }
1822
1823    // ── Helpers ─────────────────────────────────────────────────────────
1824
1825    fn matches_any_key(key: Option<&str>, candidates: &[&str]) -> bool {
1826        key.is_some_and(|k| candidates.contains(&k))
1827    }
1828
1829    fn transport_id_placeholder(key: Option<&str>) -> Option<&'static str> {
1830        let k = key?;
1831        TRANSPORT_ID_KEYS
1832            .iter()
1833            .find(|(name, _)| *name == k)
1834            .map(|(_, placeholder)| *placeholder)
1835    }
1836
1837    fn fixed_placeholder(key: Option<&str>) -> Option<&'static str> {
1838        let k = key?;
1839        FIXED_PLACEHOLDER_KEYS
1840            .iter()
1841            .find(|(name, _)| *name == k)
1842            .map(|(_, placeholder)| *placeholder)
1843    }
1844
1845    /// If `map` represents an `extension_ui_request` event, replace its
1846    /// `method` value with the canonical short form via [`canonicalize_op_name`].
1847    fn canonicalize_ui_method(map: &mut serde_json::Map<String, Value>) {
1848        let is_ui_request = map
1849            .get("type")
1850            .and_then(Value::as_str)
1851            .is_some_and(|t| t == "extension_ui_request");
1852        if !is_ui_request {
1853            return;
1854        }
1855        if let Some(Value::String(method)) = map.get_mut("method") {
1856            let canonical = canonicalize_op_name(method);
1857            if canonical != method.as_str() {
1858                *method = canonical.to_string();
1859            }
1860        }
1861    }
1862
1863    // ── Alias mapping (unblocks bd-k5q5.1.4) ──────────────────────────
1864
1865    /// Known UI operation aliases that map to a canonical name.
1866    ///
1867    /// Extensions may use either form; the normalization contract treats
1868    /// them as equivalent during comparison.  The canonical form is the
1869    /// short verb (e.g. `"status"`) so that `setStatus`, `set_status`,
1870    /// and `status` all compare equal after normalization.
1871    pub const UI_OP_ALIASES: &[(&str, &str)] = &[
1872        ("setStatus", "status"),
1873        ("set_status", "status"),
1874        ("setLabel", "label"),
1875        ("set_label", "label"),
1876        ("setWidget", "widget"),
1877        ("set_widget", "widget"),
1878        ("setTitle", "title"),
1879        ("set_title", "title"),
1880    ];
1881
1882    /// Resolve an operation name to its canonical form.
1883    #[must_use]
1884    pub fn canonicalize_op_name(op: &str) -> &str {
1885        UI_OP_ALIASES
1886            .iter()
1887            .find(|(alias, _)| *alias == op)
1888            .map_or(op, |(_, canonical)| canonical)
1889    }
1890
1891    // ── Path canonicalization for conformance assertions (bd-k5q5.1.2) ─
1892
1893    /// Returns `true` if `key` names a JSON field whose values are
1894    /// filesystem paths (e.g. `promptPaths`, `filePath`, `cwd`).
1895    ///
1896    /// The heuristic matches keys that end with common path suffixes or
1897    /// are well-known path keys.  This is intentionally conservative;
1898    /// it is better to match too little (and fail a test explicitly) than
1899    /// too much (and mask a real mismatch).
1900    #[must_use]
1901    pub fn is_path_key(key: &str) -> bool {
1902        key.ends_with("Path")
1903            || key.ends_with("Paths")
1904            || key.ends_with("path")
1905            || key.ends_with("paths")
1906            || key.ends_with("Dir")
1907            || key.ends_with("dir")
1908            || key == "cwd"
1909    }
1910
1911    /// Path-aware suffix match for conformance assertions.
1912    ///
1913    /// Returns `true` when `actual` and `expected` refer to the same file:
1914    ///
1915    /// - If they are identical → `true`.
1916    /// - If `expected` is *relative* (no leading `/` or `\`) and `actual`
1917    ///   ends with `/<expected>` → `true`.
1918    /// - Otherwise → `false`.
1919    ///
1920    /// This handles the common case where fixtures record relative
1921    /// filenames (`SKILL.md`) while the runtime returns absolute paths
1922    /// (`/data/projects/.../SKILL.md`).
1923    #[must_use]
1924    pub fn path_suffix_match(actual: &str, expected: &str) -> bool {
1925        if actual == expected {
1926            return true;
1927        }
1928        // Only apply suffix matching when expected is relative.
1929        if expected.starts_with('/') || expected.starts_with('\\') {
1930            return false;
1931        }
1932        // Normalize backslashes for cross-platform comparison.
1933        let actual_norm = actual.replace('\\', "/");
1934        let expected_norm = expected.replace('\\', "/");
1935        actual_norm.ends_with(&format!("/{expected_norm}"))
1936    }
1937
1938    // ── Tests ──────────────────────────────────────────────────────────
1939
1940    #[cfg(test)]
1941    mod tests {
1942        use super::*;
1943        use serde_json::json;
1944
1945        #[test]
1946        fn schema_version_is_set() {
1947            assert!(!SCHEMA_VERSION.is_empty());
1948            assert_eq!(
1949                SCHEMA_VERSION.split('.').count(),
1950                3,
1951                "semver format expected"
1952            );
1953        }
1954
1955        #[test]
1956        fn default_contract_has_field_rules() {
1957            let contract = NormalizationContract::default();
1958            assert!(
1959                !contract.field_rules.is_empty(),
1960                "default contract must have rules"
1961            );
1962            assert_eq!(contract.schema_version, SCHEMA_VERSION);
1963        }
1964
1965        #[test]
1966        fn field_classification_serde_roundtrip() {
1967            for class in [
1968                FieldClassification::Semantic,
1969                FieldClassification::Transport,
1970                FieldClassification::Derived,
1971            ] {
1972                let json = serde_json::to_string(&class).unwrap();
1973                let back: FieldClassification = serde_json::from_str(&json).unwrap();
1974                assert_eq!(class, back);
1975            }
1976        }
1977
1978        #[test]
1979        fn normalize_timestamp_string() {
1980            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
1981            let mut val = json!({"ts": "2026-02-03T03:01:02.123Z"});
1982            normalize_value(&mut val, None, &ctx);
1983            assert_eq!(val["ts"], PLACEHOLDER_TIMESTAMP);
1984        }
1985
1986        #[test]
1987        fn normalize_timestamp_number() {
1988            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
1989            let mut val = json!({"ts": 1_700_000_000_000_u64});
1990            normalize_value(&mut val, None, &ctx);
1991            assert_eq!(val["ts"], 0);
1992        }
1993
1994        #[test]
1995        fn normalize_transport_ids() {
1996            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
1997            let mut val = json!({
1998                "session_id": "sess-abc",
1999                "run_id": "run-xyz",
2000                "artifact_id": "art-123",
2001                "trace_id": "tr-456",
2002                "span_id": "sp-789"
2003            });
2004            normalize_value(&mut val, None, &ctx);
2005            assert_eq!(val["session_id"], PLACEHOLDER_SESSION_ID);
2006            assert_eq!(val["run_id"], PLACEHOLDER_RUN_ID);
2007            assert_eq!(val["artifact_id"], PLACEHOLDER_ARTIFACT_ID);
2008            assert_eq!(val["trace_id"], PLACEHOLDER_TRACE_ID);
2009            assert_eq!(val["span_id"], PLACEHOLDER_SPAN_ID);
2010        }
2011
2012        #[test]
2013        fn normalize_camel_case_variants() {
2014            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2015            let mut val = json!({
2016                "sessionId": "sess-abc",
2017                "runId": "run-xyz",
2018                "artifactId": "art-123",
2019                "traceId": "tr-456",
2020                "spanId": "sp-789",
2021                "createdAt": "2026-01-01"
2022            });
2023            normalize_value(&mut val, None, &ctx);
2024            assert_eq!(val["sessionId"], PLACEHOLDER_SESSION_ID);
2025            assert_eq!(val["runId"], PLACEHOLDER_RUN_ID);
2026            assert_eq!(val["artifactId"], PLACEHOLDER_ARTIFACT_ID);
2027            assert_eq!(val["traceId"], PLACEHOLDER_TRACE_ID);
2028            assert_eq!(val["spanId"], PLACEHOLDER_SPAN_ID);
2029            assert_eq!(val["createdAt"], PLACEHOLDER_TIMESTAMP);
2030        }
2031
2032        #[test]
2033        fn normalize_fixed_keys() {
2034            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2035            let mut val = json!({"cwd": "/some/path", "host": "myhost.local"});
2036            normalize_value(&mut val, None, &ctx);
2037            assert_eq!(val["cwd"], PLACEHOLDER_PI_MONO_ROOT);
2038            assert_eq!(val["host"], PLACEHOLDER_HOST);
2039        }
2040
2041        #[test]
2042        fn normalize_pid() {
2043            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2044            let mut val = json!({"source": {"pid": 42}});
2045            normalize_value(&mut val, None, &ctx);
2046            assert_eq!(val["source"]["pid"], 0);
2047        }
2048
2049        #[test]
2050        fn normalize_string_strips_ansi() {
2051            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2052            let input = "\x1b[31mERROR\x1b[0m: something failed";
2053            let out = normalize_string(input, &ctx);
2054            assert_eq!(out, "ERROR: something failed");
2055        }
2056
2057        #[test]
2058        fn normalize_string_rewrites_uuids() {
2059            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2060            let input = "id=123e4567-e89b-12d3-a456-426614174000";
2061            let out = normalize_string(input, &ctx);
2062            assert!(out.contains(PLACEHOLDER_UUID), "got: {out}");
2063        }
2064
2065        #[test]
2066        fn normalize_string_rewrites_run_ids() {
2067            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2068            let input = "run-123e4567-e89b-12d3-a456-426614174000";
2069            let out = normalize_string(input, &ctx);
2070            assert!(out.contains(PLACEHOLDER_RUN_ID), "got: {out}");
2071        }
2072
2073        #[test]
2074        fn normalize_string_rewrites_ports() {
2075            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2076            let input = "http://127.0.0.1:4887/v1/chat";
2077            let out = normalize_string(input, &ctx);
2078            assert!(
2079                out.contains(&format!("http://127.0.0.1:{PLACEHOLDER_PORT}/v1")),
2080                "got: {out}"
2081            );
2082        }
2083
2084        #[test]
2085        fn normalize_string_rewrites_paths() {
2086            let ctx = NormalizationContext::new(
2087                "/repo/pi".to_string(),
2088                "/repo/pi/legacy_pi_mono_code/pi-mono".to_string(),
2089                "/tmp/work".to_string(),
2090            );
2091            let input = "opened /tmp/work/file.txt and /repo/pi/src/main.rs";
2092            let out = normalize_string(input, &ctx);
2093            assert!(
2094                out.contains(&format!("{PLACEHOLDER_PI_MONO_ROOT}/file.txt")),
2095                "got: {out}"
2096            );
2097            assert!(
2098                out.contains(&format!("{PLACEHOLDER_PROJECT_ROOT}/src/main.rs")),
2099                "got: {out}"
2100            );
2101        }
2102
2103        #[test]
2104        fn canonicalize_json_keys_sorts_recursively() {
2105            let input = json!({"z": 1, "a": {"c": 3, "b": 2}});
2106            let out = canonicalize_json_keys(&input);
2107            let serialized = serde_json::to_string(&out).unwrap();
2108            assert_eq!(serialized, r#"{"a":{"b":2,"c":3},"z":1}"#);
2109        }
2110
2111        #[test]
2112        fn contract_normalize_and_canonicalize() {
2113            let contract = NormalizationContract::default();
2114            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2115            let input = json!({
2116                "z_field": "hello",
2117                "ts": "2026-01-01",
2118                "a_field": 42,
2119                "session_id": "sess-x"
2120            });
2121            let out = contract.normalize_and_canonicalize(input, &ctx);
2122            assert_eq!(out["ts"], PLACEHOLDER_TIMESTAMP);
2123            assert_eq!(out["session_id"], PLACEHOLDER_SESSION_ID);
2124            // Keys should be sorted
2125            let keys: Vec<&String> = out.as_object().unwrap().keys().collect();
2126            let mut sorted = keys.clone();
2127            sorted.sort();
2128            assert_eq!(keys, sorted);
2129        }
2130
2131        #[test]
2132        fn canonicalize_op_name_resolves_aliases() {
2133            assert_eq!(canonicalize_op_name("setStatus"), "status");
2134            assert_eq!(canonicalize_op_name("set_status"), "status");
2135            assert_eq!(canonicalize_op_name("setLabel"), "label");
2136            assert_eq!(canonicalize_op_name("set_label"), "label");
2137            assert_eq!(canonicalize_op_name("setWidget"), "widget");
2138            assert_eq!(canonicalize_op_name("set_widget"), "widget");
2139            assert_eq!(canonicalize_op_name("setTitle"), "title");
2140            assert_eq!(canonicalize_op_name("set_title"), "title");
2141            // Already-canonical and unknown ops pass through
2142            assert_eq!(canonicalize_op_name("status"), "status");
2143            assert_eq!(canonicalize_op_name("notify"), "notify");
2144            assert_eq!(canonicalize_op_name("unknown_op"), "unknown_op");
2145        }
2146
2147        #[test]
2148        fn normalize_canonicalizes_ui_method() {
2149            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2150            let mut input = json!({
2151                "type": "extension_ui_request",
2152                "id": "req-1",
2153                "method": "setStatus",
2154                "statusKey": "demo",
2155                "statusText": "Ready"
2156            });
2157            normalize_value(&mut input, None, &ctx);
2158            assert_eq!(
2159                input["method"], "status",
2160                "setStatus should be canonicalized to status"
2161            );
2162        }
2163
2164        #[test]
2165        fn normalize_skips_non_ui_request_method() {
2166            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2167            let mut input = json!({
2168                "type": "http_request",
2169                "method": "setStatus"
2170            });
2171            normalize_value(&mut input, None, &ctx);
2172            assert_eq!(
2173                input["method"], "setStatus",
2174                "non-ui-request method should NOT be canonicalized"
2175            );
2176        }
2177
2178        #[test]
2179        fn normalize_and_canonicalize_handles_ui_aliases() {
2180            let contract = NormalizationContract::default();
2181            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2182            // Two events that differ only by method naming
2183            let event_camel = json!({
2184                "type": "extension_ui_request",
2185                "method": "setStatus",
2186                "statusKey": "k"
2187            });
2188            let event_snake = json!({
2189                "type": "extension_ui_request",
2190                "method": "set_status",
2191                "statusKey": "k"
2192            });
2193            let a = contract.normalize_and_canonicalize(event_camel, &ctx);
2194            let b = contract.normalize_and_canonicalize(event_snake, &ctx);
2195            assert_eq!(
2196                a, b,
2197                "setStatus and set_status should normalize identically"
2198            );
2199            assert_eq!(a["method"], "status");
2200        }
2201
2202        #[test]
2203        fn contract_serializes_to_json() {
2204            let contract = NormalizationContract::default();
2205            let json = serde_json::to_string_pretty(&contract).unwrap();
2206            assert!(json.contains("schema_version"));
2207            assert!(json.contains("field_rules"));
2208            // Roundtrip
2209            let back: NormalizationContract = serde_json::from_str(&json).unwrap();
2210            assert_eq!(back.schema_version, SCHEMA_VERSION);
2211            assert_eq!(back.field_rules.len(), contract.field_rules.len());
2212        }
2213
2214        #[test]
2215        fn default_contract_covers_all_transport_keys() {
2216            let contract = NormalizationContract::default();
2217            let transport_rules: Vec<_> = contract
2218                .field_rules
2219                .iter()
2220                .filter(|r| r.classification == FieldClassification::Transport)
2221                .collect();
2222            // At minimum: 6 timestamp + 10 transport IDs + 2 fixed + 1 pid = 19
2223            assert!(
2224                transport_rules.len() >= 19,
2225                "expected >= 19 transport rules, got {}",
2226                transport_rules.len()
2227            );
2228        }
2229
2230        #[test]
2231        fn default_contract_has_semantic_rules() {
2232            let contract = NormalizationContract::default();
2233            assert!(
2234                contract
2235                    .field_rules
2236                    .iter()
2237                    .any(|r| r.classification == FieldClassification::Semantic),
2238                "contract should document semantic fields"
2239            );
2240        }
2241
2242        // ── Path canonicalization tests (bd-k5q5.1.2) ────────────────
2243
2244        #[test]
2245        fn is_path_key_matches_common_suffixes() {
2246            assert!(is_path_key("promptPaths"));
2247            assert!(is_path_key("skillPaths"));
2248            assert!(is_path_key("themePaths"));
2249            assert!(is_path_key("filePath"));
2250            assert!(is_path_key("cwd"));
2251            assert!(is_path_key("workingDir"));
2252            assert!(!is_path_key("method"));
2253            assert!(!is_path_key("statusKey"));
2254            assert!(!is_path_key("name"));
2255        }
2256
2257        #[test]
2258        fn path_suffix_match_exact() {
2259            assert!(path_suffix_match("SKILL.md", "SKILL.md"));
2260            assert!(path_suffix_match("/a/b/c.txt", "/a/b/c.txt"));
2261        }
2262
2263        #[test]
2264        fn path_suffix_match_relative_in_absolute() {
2265            assert!(path_suffix_match(
2266                "/data/projects/pi/tests/ext_conformance/artifacts/dynamic-resources/SKILL.md",
2267                "SKILL.md"
2268            ));
2269            assert!(path_suffix_match(
2270                "/data/projects/pi/tests/ext_conformance/artifacts/dynamic-resources/dynamic.md",
2271                "dynamic.md"
2272            ));
2273        }
2274
2275        #[test]
2276        fn path_suffix_match_multi_component_relative() {
2277            assert!(path_suffix_match(
2278                "/data/projects/ext/sub/dir/file.ts",
2279                "dir/file.ts"
2280            ));
2281            assert!(!path_suffix_match(
2282                "/data/projects/ext/sub/dir/file.ts",
2283                "other/file.ts"
2284            ));
2285        }
2286
2287        #[test]
2288        fn path_suffix_match_rejects_when_expected_is_absolute() {
2289            // Two different absolute paths should not match via suffix.
2290            assert!(!path_suffix_match("/a/b/c.txt", "/x/y/c.txt"));
2291        }
2292
2293        #[test]
2294        fn path_suffix_match_handles_backslashes() {
2295            assert!(path_suffix_match(
2296                "C:\\Users\\dev\\project\\SKILL.md",
2297                "SKILL.md"
2298            ));
2299        }
2300
2301        // ── Harness unit-test expansion (bd-k5q5.7.2) ──────────────────
2302
2303        #[test]
2304        fn normalize_deeply_nested_mixed_fields() {
2305            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2306            let mut val = json!({
2307                "outer": {
2308                    "inner": {
2309                        "session_id": "sess-deep",
2310                        "semantic_data": "keep me",
2311                        "ts": "2026-01-01T00:00:00Z",
2312                        "nested_array": [
2313                            { "pid": 99, "name": "tool-a" },
2314                            { "host": "deep-host", "value": 42 }
2315                        ]
2316                    }
2317                }
2318            });
2319            normalize_value(&mut val, None, &ctx);
2320            assert_eq!(val["outer"]["inner"]["session_id"], PLACEHOLDER_SESSION_ID);
2321            assert_eq!(val["outer"]["inner"]["semantic_data"], "keep me");
2322            assert_eq!(val["outer"]["inner"]["ts"], PLACEHOLDER_TIMESTAMP);
2323            assert_eq!(val["outer"]["inner"]["nested_array"][0]["pid"], 0);
2324            assert_eq!(val["outer"]["inner"]["nested_array"][0]["name"], "tool-a");
2325            assert_eq!(
2326                val["outer"]["inner"]["nested_array"][1]["host"],
2327                PLACEHOLDER_HOST
2328            );
2329            assert_eq!(val["outer"]["inner"]["nested_array"][1]["value"], 42);
2330        }
2331
2332        #[test]
2333        fn normalize_array_of_events() {
2334            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2335            let mut val = json!([
2336                { "ts": "2026-01-01", "session_id": "s1", "event": "start" },
2337                { "ts": "2026-01-02", "session_id": "s2", "event": "end" }
2338            ]);
2339            normalize_value(&mut val, None, &ctx);
2340            assert_eq!(val[0]["ts"], PLACEHOLDER_TIMESTAMP);
2341            assert_eq!(val[0]["session_id"], PLACEHOLDER_SESSION_ID);
2342            assert_eq!(val[0]["event"], "start");
2343            assert_eq!(val[1]["ts"], PLACEHOLDER_TIMESTAMP);
2344            assert_eq!(val[1]["session_id"], PLACEHOLDER_SESSION_ID);
2345            assert_eq!(val[1]["event"], "end");
2346        }
2347
2348        #[test]
2349        fn normalize_empty_structures_unchanged() {
2350            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2351            let mut empty_obj = json!({});
2352            normalize_value(&mut empty_obj, None, &ctx);
2353            assert_eq!(empty_obj, json!({}));
2354
2355            let mut empty_arr = json!([]);
2356            normalize_value(&mut empty_arr, None, &ctx);
2357            assert_eq!(empty_arr, json!([]));
2358
2359            let mut null_val = Value::Null;
2360            normalize_value(&mut null_val, None, &ctx);
2361            assert!(null_val.is_null());
2362
2363            let mut bool_val = json!(true);
2364            normalize_value(&mut bool_val, None, &ctx);
2365            assert_eq!(bool_val, true);
2366        }
2367
2368        #[test]
2369        fn normalize_string_combined_patterns() {
2370            let ctx = NormalizationContext::new(
2371                "/repo/pi".to_string(),
2372                "/repo/pi/legacy".to_string(),
2373                "/tmp/work".to_string(),
2374            );
2375            let input = "\x1b[31mrun-123e4567-e89b-12d3-a456-426614174000\x1b[0m at /tmp/work/test.rs with id=deadbeef-dead-beef-dead-beefdeadbeef http://127.0.0.1:9999/v1/api";
2376            let out = normalize_string(input, &ctx);
2377            assert!(!out.contains("\x1b["), "ANSI should be stripped");
2378            assert!(out.contains(PLACEHOLDER_RUN_ID), "run-ID: {out}");
2379            assert!(out.contains(PLACEHOLDER_PI_MONO_ROOT), "path: {out}");
2380            assert!(out.contains(PLACEHOLDER_UUID), "UUID: {out}");
2381            assert!(out.contains(PLACEHOLDER_PORT), "port: {out}");
2382        }
2383
2384        #[test]
2385        fn normalize_path_canonicalization_overlapping_roots() {
2386            // When cwd is inside pi_mono_root, both should be replaced correctly.
2387            let ctx = NormalizationContext::new(
2388                "/repo".to_string(),
2389                "/repo/legacy/pi-mono".to_string(),
2390                "/repo/legacy/pi-mono/test-dir".to_string(),
2391            );
2392            // The cwd path should be replaced first (most-specific match).
2393            let input = "file at /repo/legacy/pi-mono/test-dir/output.txt";
2394            let out = normalize_string(input, &ctx);
2395            assert!(
2396                out.contains(PLACEHOLDER_PI_MONO_ROOT),
2397                "cwd inside pi_mono: {out}"
2398            );
2399            assert!(
2400                !out.contains("/repo/legacy/pi-mono/test-dir"),
2401                "original cwd should be gone: {out}"
2402            );
2403            // The cwd-specific directory should not leak through.
2404            assert!(
2405                !out.contains("test-dir"),
2406                "cwd subdirectory remnant should be normalized away: {out}"
2407            );
2408        }
2409
2410        #[test]
2411        fn normalize_idempotent() {
2412            let ctx = NormalizationContext::new(
2413                "/repo".to_string(),
2414                "/repo/legacy".to_string(),
2415                "/tmp/work".to_string(),
2416            );
2417            let contract = NormalizationContract::default();
2418            let input = json!({
2419                "ts": "2026-01-01",
2420                "session_id": "sess-x",
2421                "host": "myhost",
2422                "pid": 42,
2423                "message": "\x1b[31m/tmp/work/file.txt\x1b[0m"
2424            });
2425            let first = contract.normalize_and_canonicalize(input, &ctx);
2426            let second = contract.normalize_and_canonicalize(first.clone(), &ctx);
2427            assert_eq!(first, second, "normalization must be idempotent");
2428        }
2429
2430        #[test]
2431        fn normalize_preserves_all_semantic_fields() {
2432            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2433            let mut val = json!({
2434                "schema": "pi.ext.log.v1",
2435                "level": "info",
2436                "event": "tool_call.start",
2437                "extension_id": "ext.demo",
2438                "data": { "key": "value", "nested": [1, 2, 3] }
2439            });
2440            let original = val.clone();
2441            normalize_value(&mut val, None, &ctx);
2442            assert_eq!(val["schema"], original["schema"]);
2443            assert_eq!(val["level"], original["level"]);
2444            assert_eq!(val["event"], original["event"]);
2445            assert_eq!(val["extension_id"], original["extension_id"]);
2446            assert_eq!(val["data"]["key"], "value");
2447            assert_eq!(val["data"]["nested"], json!([1, 2, 3]));
2448        }
2449
2450        #[test]
2451        fn normalize_all_timestamp_key_variants() {
2452            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2453            for key in &[
2454                "timestamp",
2455                "started_at",
2456                "finished_at",
2457                "created_at",
2458                "createdAt",
2459                "ts",
2460            ] {
2461                let mut val =
2462                    serde_json::from_str(&format!(r#"{{"{key}": "2026-01-01T00:00:00Z"}}"#))
2463                        .unwrap();
2464                normalize_value(&mut val, None, &ctx);
2465                assert_eq!(
2466                    val[key], PLACEHOLDER_TIMESTAMP,
2467                    "key {key} should be normalized"
2468                );
2469            }
2470        }
2471
2472        #[test]
2473        fn normalize_numeric_timestamp_keys_zeroed() {
2474            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2475            for key in &["timestamp", "started_at", "finished_at", "ts"] {
2476                let mut val = serde_json::from_str(&format!(r#"{{"{key}": 1700000000}}"#)).unwrap();
2477                normalize_value(&mut val, None, &ctx);
2478                assert_eq!(val[key], 0, "numeric {key} should be zeroed");
2479            }
2480        }
2481
2482        #[test]
2483        fn canonicalize_json_keys_preserves_array_order() {
2484            let input = json!({"items": [3, 1, 2], "z": "last", "a": "first"});
2485            let out = canonicalize_json_keys(&input);
2486            // Keys sorted, but array order preserved
2487            let keys: Vec<&String> = out.as_object().unwrap().keys().collect();
2488            assert_eq!(keys, &["a", "items", "z"]);
2489            assert_eq!(out["items"], json!([3, 1, 2]));
2490        }
2491
2492        #[test]
2493        fn canonicalize_json_keys_nested_arrays_of_objects() {
2494            let input = json!({
2495                "b": [
2496                    {"z": 1, "a": 2},
2497                    {"y": 3, "b": 4}
2498                ],
2499                "a": "first"
2500            });
2501            let out = canonicalize_json_keys(&input);
2502            let serialized = serde_json::to_string(&out).unwrap();
2503            // Top-level keys sorted: "a" before "b"
2504            // Object keys inside array sorted: "a" before "z", "b" before "y"
2505            assert_eq!(
2506                serialized,
2507                r#"{"a":"first","b":[{"a":2,"z":1},{"b":4,"y":3}]}"#
2508            );
2509        }
2510
2511        #[test]
2512        fn canonicalize_json_keys_scalar_values_unchanged() {
2513            assert_eq!(canonicalize_json_keys(&json!(42)), json!(42));
2514            assert_eq!(canonicalize_json_keys(&json!("hello")), json!("hello"));
2515            assert_eq!(canonicalize_json_keys(&json!(true)), json!(true));
2516            assert_eq!(canonicalize_json_keys(&json!(null)), json!(null));
2517        }
2518
2519        #[test]
2520        fn normalize_string_no_match_returns_unchanged() {
2521            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2522            let input = "plain text with no special patterns";
2523            let out = normalize_string(input, &ctx);
2524            assert_eq!(out, input);
2525        }
2526
2527        #[test]
2528        fn normalize_string_multiple_uuids() {
2529            let ctx = NormalizationContext::new(String::new(), String::new(), String::new());
2530            let input = "ids: 11111111-2222-3333-4444-555555555555 and 66666666-7777-8888-9999-aaaaaaaaaaaa";
2531            let out = normalize_string(input, &ctx);
2532            let count = out.matches(PLACEHOLDER_UUID).count();
2533            assert_eq!(count, 2, "both UUIDs should be replaced: {out}");
2534        }
2535
2536        #[test]
2537        fn is_path_key_additional_patterns() {
2538            assert!(is_path_key("outputPath"));
2539            assert!(is_path_key("inputDir"));
2540            assert!(is_path_key("rootdir"));
2541            assert!(is_path_key("filePaths"));
2542            assert!(!is_path_key("method"));
2543            assert!(!is_path_key("status"));
2544            assert!(!is_path_key(""));
2545        }
2546
2547        #[test]
2548        fn path_suffix_match_empty_strings() {
2549            assert!(path_suffix_match("", ""));
2550            assert!(!path_suffix_match("file.txt", ""));
2551            assert!(!path_suffix_match("", "file.txt"));
2552        }
2553
2554        #[test]
2555        fn path_suffix_match_partial_filename_no_match() {
2556            // "LL.md" should not match "SKILL.md" via suffix
2557            assert!(!path_suffix_match("/path/to/SKILL.md", "LL.md"));
2558        }
2559
2560        #[test]
2561        fn replace_path_variants_empty_path_noop() {
2562            let result = super::replace_path_variants("some input text", "", "PLACEHOLDER");
2563            assert_eq!(result, "some input text");
2564        }
2565
2566        #[test]
2567        fn replace_path_variants_backslash_form() {
2568            let result =
2569                super::replace_path_variants("C:\\repo\\pi\\file.rs", "/repo/pi", "<ROOT>");
2570            // The forward-slash form won't match, but the backslash variant should.
2571            // Actually, replace_path_variants replaces both forward and backslash.
2572            // Input has backslashes, path is forward-slash. The backslash variant
2573            // of path (\repo\pi) should match.
2574            assert!(
2575                result.contains("<ROOT>"),
2576                "backslash variant should match: {result}"
2577            );
2578        }
2579
2580        #[test]
2581        fn context_new_explicit_paths() {
2582            let ctx =
2583                NormalizationContext::new("/a".to_string(), "/b".to_string(), "/c".to_string());
2584            assert_eq!(ctx.project_root, "/a");
2585            assert_eq!(ctx.pi_mono_root, "/b");
2586            assert_eq!(ctx.cwd, "/c");
2587        }
2588
2589        #[test]
2590        fn canonicalize_ui_method_non_ui_type_untouched() {
2591            let mut map = serde_json::Map::new();
2592            map.insert("type".into(), json!("rpc_request"));
2593            map.insert("method".into(), json!("setStatus"));
2594            super::canonicalize_ui_method(&mut map);
2595            assert_eq!(map["method"], "setStatus");
2596        }
2597
2598        #[test]
2599        fn canonicalize_ui_method_missing_type_untouched() {
2600            let mut map = serde_json::Map::new();
2601            map.insert("method".into(), json!("setStatus"));
2602            super::canonicalize_ui_method(&mut map);
2603            assert_eq!(map["method"], "setStatus");
2604        }
2605
2606        #[test]
2607        fn canonicalize_ui_method_unknown_method_untouched() {
2608            let mut map = serde_json::Map::new();
2609            map.insert("type".into(), json!("extension_ui_request"));
2610            map.insert("method".into(), json!("customOp"));
2611            super::canonicalize_ui_method(&mut map);
2612            assert_eq!(map["method"], "customOp");
2613        }
2614
2615        #[test]
2616        fn canonicalize_ui_method_all_aliases() {
2617            for &(alias, canonical) in UI_OP_ALIASES {
2618                let mut map = serde_json::Map::new();
2619                map.insert("type".into(), json!("extension_ui_request"));
2620                map.insert("method".into(), json!(alias));
2621                super::canonicalize_ui_method(&mut map);
2622                assert_eq!(
2623                    map["method"].as_str().unwrap(),
2624                    canonical,
2625                    "alias {alias} should canonicalize to {canonical}"
2626                );
2627            }
2628        }
2629
2630        #[test]
2631        fn matches_any_key_none_returns_false() {
2632            assert!(!super::matches_any_key(None, &["ts", "pid"]));
2633        }
2634
2635        #[test]
2636        fn transport_id_placeholder_known_keys() {
2637            assert_eq!(
2638                super::transport_id_placeholder(Some("session_id")),
2639                Some(PLACEHOLDER_SESSION_ID)
2640            );
2641            assert_eq!(
2642                super::transport_id_placeholder(Some("sessionId")),
2643                Some(PLACEHOLDER_SESSION_ID)
2644            );
2645            assert_eq!(
2646                super::transport_id_placeholder(Some("run_id")),
2647                Some(PLACEHOLDER_RUN_ID)
2648            );
2649            assert_eq!(super::transport_id_placeholder(Some("unknown")), None);
2650            assert_eq!(super::transport_id_placeholder(None), None);
2651        }
2652
2653        #[test]
2654        fn fixed_placeholder_known_keys() {
2655            assert_eq!(
2656                super::fixed_placeholder(Some("cwd")),
2657                Some(PLACEHOLDER_PI_MONO_ROOT)
2658            );
2659            assert_eq!(
2660                super::fixed_placeholder(Some("host")),
2661                Some(PLACEHOLDER_HOST)
2662            );
2663            assert_eq!(super::fixed_placeholder(Some("other")), None);
2664            assert_eq!(super::fixed_placeholder(None), None);
2665        }
2666    }
2667}
2668
2669#[cfg(test)]
2670mod tests {
2671    use super::compare_conformance_output;
2672    use super::report::compute_regression;
2673    use super::report::generate_report;
2674    use super::report::{ConformanceDiffEntry, ConformanceStatus, ExtensionConformanceResult};
2675    use super::snapshot::{
2676        self, ArtifactSource, ArtifactSpec, SourceTier, validate_artifact_spec, validate_directory,
2677        validate_id,
2678    };
2679    use proptest::prelude::*;
2680    use proptest::string::string_regex;
2681    use serde_json::{Map, Value, json};
2682    use std::collections::BTreeSet;
2683
2684    #[test]
2685    fn ignores_registration_ordering_by_key() {
2686        let expected = json!({
2687            "extension_id": "ext",
2688            "name": "Ext",
2689            "version": "1.0.0",
2690            "registrations": {
2691                "commands": [
2692                    { "name": "b", "description": "B" },
2693                    { "name": "a", "description": "A" }
2694                ],
2695                "shortcuts": [
2696                    { "key_id": "ctrl+a", "description": "A" }
2697                ],
2698                "flags": [],
2699                "providers": [],
2700                "tool_defs": [],
2701                "models": [],
2702                "event_hooks": ["on_message", "on_tool"]
2703            },
2704            "hostcall_log": []
2705        });
2706
2707        let actual = json!({
2708            "extension_id": "ext",
2709            "name": "Ext",
2710            "version": "1.0.0",
2711            "registrations": {
2712                "commands": [
2713                    { "name": "a", "description": "A" },
2714                    { "name": "b", "description": "B" }
2715                ],
2716                "shortcuts": [
2717                    { "description": "A", "key_id": "ctrl+a" }
2718                ],
2719                "flags": [],
2720                "providers": [],
2721                "tool_defs": [],
2722                "models": [],
2723                "event_hooks": ["on_tool", "on_message"]
2724            },
2725            "hostcall_log": []
2726        });
2727
2728        compare_conformance_output(&expected, &actual).unwrap();
2729    }
2730
2731    #[test]
2732    fn hostcall_log_is_order_sensitive() {
2733        let expected = json!({
2734            "extension_id": "ext",
2735            "name": "Ext",
2736            "version": "1.0.0",
2737            "registrations": {
2738                "commands": [],
2739                "shortcuts": [],
2740                "flags": [],
2741                "providers": [],
2742                "tool_defs": [],
2743                "models": [],
2744                "event_hooks": []
2745            },
2746            "hostcall_log": [
2747                { "op": "get_state", "result": { "a": 1 } },
2748                { "op": "set_name", "payload": { "name": "x" } }
2749            ]
2750        });
2751
2752        let actual = json!({
2753            "extension_id": "ext",
2754            "name": "Ext",
2755            "version": "1.0.0",
2756            "registrations": {
2757                "commands": [],
2758                "shortcuts": [],
2759                "flags": [],
2760                "providers": [],
2761                "tool_defs": [],
2762                "models": [],
2763                "event_hooks": []
2764            },
2765            "hostcall_log": [
2766                { "op": "set_name", "payload": { "name": "x" } },
2767                { "op": "get_state", "result": { "a": 1 } }
2768            ]
2769        });
2770
2771        let err = compare_conformance_output(&expected, &actual).unwrap_err();
2772        assert!(err.contains("HOSTCALL"), "missing HOSTCALL header: {err}");
2773        assert!(
2774            err.contains("hostcall_log[0].op"),
2775            "expected path to mention index 0 op: {err}"
2776        );
2777    }
2778
2779    #[test]
2780    fn treats_missing_as_null_and_empty_array_equivalent() {
2781        let expected = json!({
2782            "extension_id": "ext",
2783            "name": "Ext",
2784            "version": "1.0.0",
2785            "registrations": {
2786                "commands": [
2787                    { "name": "a", "description": null }
2788                ],
2789                "shortcuts": [],
2790                "flags": [],
2791                "providers": [],
2792                "tool_defs": [],
2793                "models": [],
2794                "event_hooks": []
2795            },
2796            "hostcall_log": []
2797        });
2798
2799        let actual = json!({
2800            "extension_id": "ext",
2801            "name": "Ext",
2802            "version": "1.0.0",
2803            "registrations": {
2804                "commands": [
2805                    { "name": "a" }
2806                ],
2807                "shortcuts": [],
2808                "flags": [],
2809                "providers": [],
2810                "tool_defs": [],
2811                "models": [],
2812                "event_hooks": []
2813            }
2814        });
2815
2816        compare_conformance_output(&expected, &actual).unwrap();
2817    }
2818
2819    #[test]
2820    fn compares_numbers_with_tolerance() {
2821        let expected = json!({
2822            "extension_id": "ext",
2823            "name": "Ext",
2824            "version": "1.0.0",
2825            "registrations": {
2826                "commands": [],
2827                "shortcuts": [],
2828                "flags": [],
2829                "providers": [],
2830                "tool_defs": [
2831                    { "name": "t", "parameters": { "precision": 0.1 } }
2832                ],
2833                "models": [],
2834                "event_hooks": []
2835            },
2836            "hostcall_log": []
2837        });
2838
2839        let actual = json!({
2840            "extension_id": "ext",
2841            "name": "Ext",
2842            "version": "1.0.0",
2843            "registrations": {
2844                "commands": [],
2845                "shortcuts": [],
2846                "flags": [],
2847                "providers": [],
2848                "tool_defs": [
2849                    { "name": "t", "parameters": { "precision": 0.100_000_000_000_01 } }
2850                ],
2851                "models": [],
2852                "event_hooks": []
2853            },
2854            "hostcall_log": []
2855        });
2856
2857        compare_conformance_output(&expected, &actual).unwrap();
2858    }
2859
2860    #[test]
2861    fn required_array_order_does_not_matter() {
2862        let expected = json!({
2863            "extension_id": "ext",
2864            "name": "Ext",
2865            "version": "1.0.0",
2866            "registrations": {
2867                "commands": [],
2868                "shortcuts": [],
2869                "flags": [],
2870                "providers": [],
2871                "tool_defs": [
2872                    { "name": "t", "parameters": { "required": ["b", "a"] } }
2873                ],
2874                "models": [],
2875                "event_hooks": []
2876            },
2877            "hostcall_log": []
2878        });
2879
2880        let actual = json!({
2881            "extension_id": "ext",
2882            "name": "Ext",
2883            "version": "1.0.0",
2884            "registrations": {
2885                "commands": [],
2886                "shortcuts": [],
2887                "flags": [],
2888                "providers": [],
2889                "tool_defs": [
2890                    { "name": "t", "parameters": { "required": ["a", "b"] } }
2891                ],
2892                "models": [],
2893                "event_hooks": []
2894            },
2895            "hostcall_log": []
2896        });
2897
2898        compare_conformance_output(&expected, &actual).unwrap();
2899    }
2900
2901    #[test]
2902    fn conformance_report_summarizes_and_renders_markdown() {
2903        let results = vec![
2904            ExtensionConformanceResult {
2905                id: "hello".to_string(),
2906                tier: Some(1),
2907                status: ConformanceStatus::Pass,
2908                ts_time_ms: Some(42),
2909                rust_time_ms: Some(38),
2910                diffs: Vec::new(),
2911                notes: None,
2912            },
2913            ExtensionConformanceResult {
2914                id: "event-bus".to_string(),
2915                tier: Some(2),
2916                status: ConformanceStatus::Fail,
2917                ts_time_ms: Some(55),
2918                rust_time_ms: Some(60),
2919                diffs: vec![ConformanceDiffEntry {
2920                    category: "registration.event_hooks".to_string(),
2921                    path: "registrations.event_hooks".to_string(),
2922                    message: "extra hook in Rust".to_string(),
2923                }],
2924                notes: Some("registration mismatch".to_string()),
2925            },
2926            ExtensionConformanceResult {
2927                id: "ui-heavy".to_string(),
2928                tier: Some(6),
2929                status: ConformanceStatus::Skip,
2930                ts_time_ms: None,
2931                rust_time_ms: None,
2932                diffs: Vec::new(),
2933                notes: Some("ignored in CI".to_string()),
2934            },
2935        ];
2936
2937        let report = generate_report(
2938            "run-test",
2939            Some("2026-02-05T00:00:00Z".to_string()),
2940            results,
2941        );
2942
2943        assert_eq!(report.summary.total, 3);
2944        assert_eq!(report.summary.passed, 1);
2945        assert_eq!(report.summary.failed, 1);
2946        assert_eq!(report.summary.skipped, 1);
2947        assert_eq!(report.summary.errors, 0);
2948        assert!(report.summary.by_tier.contains_key("tier1"));
2949        assert!(report.summary.by_tier.contains_key("tier2"));
2950        assert!(report.summary.by_tier.contains_key("tier6"));
2951
2952        let md = report.render_markdown();
2953        assert!(md.contains("# Extension Conformance Report"));
2954        assert!(md.contains("Run ID: run-test"));
2955        assert!(md.contains("| hello | PASS | 42ms | 38ms |"));
2956        assert!(md.contains("## Failures"));
2957        assert!(md.contains("### event-bus (Tier 2)"));
2958    }
2959
2960    #[test]
2961    fn conformance_regression_ignores_new_extensions_for_pass_rate() {
2962        let previous = generate_report(
2963            "run-prev",
2964            Some("2026-02-05T00:00:00Z".to_string()),
2965            vec![
2966                ExtensionConformanceResult {
2967                    id: "a".to_string(),
2968                    tier: Some(1),
2969                    status: ConformanceStatus::Pass,
2970                    ts_time_ms: None,
2971                    rust_time_ms: None,
2972                    diffs: Vec::new(),
2973                    notes: None,
2974                },
2975                ExtensionConformanceResult {
2976                    id: "b".to_string(),
2977                    tier: Some(1),
2978                    status: ConformanceStatus::Fail,
2979                    ts_time_ms: None,
2980                    rust_time_ms: None,
2981                    diffs: Vec::new(),
2982                    notes: None,
2983                },
2984            ],
2985        );
2986
2987        let current = generate_report(
2988            "run-cur",
2989            Some("2026-02-06T00:00:00Z".to_string()),
2990            vec![
2991                ExtensionConformanceResult {
2992                    id: "a".to_string(),
2993                    tier: Some(1),
2994                    status: ConformanceStatus::Pass,
2995                    ts_time_ms: None,
2996                    rust_time_ms: None,
2997                    diffs: Vec::new(),
2998                    notes: None,
2999                },
3000                ExtensionConformanceResult {
3001                    id: "b".to_string(),
3002                    tier: Some(1),
3003                    status: ConformanceStatus::Fail,
3004                    ts_time_ms: None,
3005                    rust_time_ms: None,
3006                    diffs: Vec::new(),
3007                    notes: None,
3008                },
3009                // New failing extension: should not count as regression.
3010                ExtensionConformanceResult {
3011                    id: "c".to_string(),
3012                    tier: Some(1),
3013                    status: ConformanceStatus::Fail,
3014                    ts_time_ms: None,
3015                    rust_time_ms: None,
3016                    diffs: Vec::new(),
3017                    notes: None,
3018                },
3019            ],
3020        );
3021
3022        let regression = compute_regression(&previous, &current);
3023        assert!(!regression.has_regression());
3024        assert_eq!(regression.compared_total, 2);
3025        assert_eq!(regression.previous_passed, 1);
3026        assert_eq!(regression.current_passed, 1);
3027    }
3028
3029    #[test]
3030    fn conformance_regression_flags_pass_to_fail() {
3031        let previous = generate_report(
3032            "run-prev",
3033            Some("2026-02-05T00:00:00Z".to_string()),
3034            vec![ExtensionConformanceResult {
3035                id: "a".to_string(),
3036                tier: Some(1),
3037                status: ConformanceStatus::Pass,
3038                ts_time_ms: None,
3039                rust_time_ms: None,
3040                diffs: Vec::new(),
3041                notes: None,
3042            }],
3043        );
3044
3045        let current = generate_report(
3046            "run-cur",
3047            Some("2026-02-06T00:00:00Z".to_string()),
3048            vec![ExtensionConformanceResult {
3049                id: "a".to_string(),
3050                tier: Some(1),
3051                status: ConformanceStatus::Fail,
3052                ts_time_ms: None,
3053                rust_time_ms: None,
3054                diffs: vec![ConformanceDiffEntry {
3055                    category: "root".to_string(),
3056                    path: "x".to_string(),
3057                    message: "changed".to_string(),
3058                }],
3059                notes: None,
3060            }],
3061        );
3062
3063        let regression = compute_regression(&previous, &current);
3064        assert!(regression.has_regression());
3065        assert_eq!(regression.regressed_extensions.len(), 1);
3066        assert_eq!(regression.regressed_extensions[0].id, "a");
3067        assert_eq!(
3068            regression.regressed_extensions[0].current,
3069            Some(ConformanceStatus::Fail)
3070        );
3071    }
3072
3073    // ================================================================
3074    // Snapshot protocol unit tests (bd-1pqf)
3075    // ================================================================
3076
3077    #[test]
3078    fn snapshot_validate_id_accepts_valid_ids() {
3079        assert!(validate_id("hello").is_ok());
3080        assert!(validate_id("auto-commit-on-exit").is_ok());
3081        assert!(validate_id("my-ext-2").is_ok());
3082        assert!(validate_id("agents-mikeastock/extensions").is_ok());
3083    }
3084
3085    #[test]
3086    fn snapshot_validate_id_rejects_invalid_ids() {
3087        assert!(validate_id("").is_err());
3088        assert!(validate_id("Hello").is_err());
3089        assert!(validate_id("my_ext").is_err());
3090        assert!(validate_id("-leading").is_err());
3091        assert!(validate_id("trailing-").is_err());
3092        assert!(validate_id("has space").is_err());
3093    }
3094
3095    #[test]
3096    fn snapshot_validate_directory_official_tier() {
3097        assert!(validate_directory("hello", SourceTier::OfficialPiMono).is_ok());
3098        assert!(validate_directory("community/x", SourceTier::OfficialPiMono).is_err());
3099        assert!(validate_directory("npm/x", SourceTier::OfficialPiMono).is_err());
3100    }
3101
3102    #[test]
3103    fn snapshot_validate_directory_scoped_tiers() {
3104        assert!(validate_directory("community/my-ext", SourceTier::Community).is_ok());
3105        assert!(validate_directory("my-ext", SourceTier::Community).is_err());
3106
3107        assert!(validate_directory("npm/some-pkg", SourceTier::NpmRegistry).is_ok());
3108        assert!(validate_directory("some-pkg", SourceTier::NpmRegistry).is_err());
3109
3110        assert!(validate_directory("third-party/repo", SourceTier::ThirdPartyGithub).is_ok());
3111        assert!(validate_directory("repo", SourceTier::ThirdPartyGithub).is_err());
3112
3113        assert!(validate_directory("templates/my-tpl", SourceTier::Templates).is_ok());
3114    }
3115
3116    #[test]
3117    fn snapshot_validate_directory_empty_rejected() {
3118        assert!(validate_directory("", SourceTier::OfficialPiMono).is_err());
3119    }
3120
3121    #[test]
3122    fn snapshot_source_tier_from_directory() {
3123        assert_eq!(
3124            SourceTier::from_directory("hello"),
3125            SourceTier::OfficialPiMono
3126        );
3127        assert_eq!(
3128            SourceTier::from_directory("community/foo"),
3129            SourceTier::Community
3130        );
3131        assert_eq!(
3132            SourceTier::from_directory("npm/bar"),
3133            SourceTier::NpmRegistry
3134        );
3135        assert_eq!(
3136            SourceTier::from_directory("third-party/baz"),
3137            SourceTier::ThirdPartyGithub
3138        );
3139        assert_eq!(
3140            SourceTier::from_directory("agents-mikeastock/ext"),
3141            SourceTier::AgentsMikeastock
3142        );
3143        assert_eq!(
3144            SourceTier::from_directory("templates/tpl"),
3145            SourceTier::Templates
3146        );
3147    }
3148
3149    #[test]
3150    fn snapshot_validate_spec_valid() {
3151        let spec = ArtifactSpec {
3152            id: "my-ext".into(),
3153            directory: "community/my-ext".into(),
3154            name: "My Extension".into(),
3155            source_tier: SourceTier::Community,
3156            license: "MIT".into(),
3157            source: ArtifactSource::Git {
3158                repo: "https://github.com/user/repo".into(),
3159                path: Some("extensions/my-ext.ts".into()),
3160                commit: None,
3161            },
3162        };
3163        assert!(validate_artifact_spec(&spec).is_empty());
3164    }
3165
3166    #[test]
3167    fn snapshot_validate_spec_collects_multiple_errors() {
3168        let spec = ArtifactSpec {
3169            id: String::new(),
3170            directory: "my-ext".into(),
3171            name: String::new(),
3172            source_tier: SourceTier::Community,
3173            license: String::new(),
3174            source: ArtifactSource::Git {
3175                repo: String::new(),
3176                path: None,
3177                commit: None,
3178            },
3179        };
3180        let errors = validate_artifact_spec(&spec);
3181        assert!(errors.len() >= 4, "expected at least 4 errors: {errors:?}");
3182    }
3183
3184    #[test]
3185    fn snapshot_validate_spec_npm_source() {
3186        let spec = ArtifactSpec {
3187            id: "npm-ext".into(),
3188            directory: "npm/npm-ext".into(),
3189            name: "NPM Extension".into(),
3190            source_tier: SourceTier::NpmRegistry,
3191            license: "UNKNOWN".into(),
3192            source: ArtifactSource::Npm {
3193                package: "npm-ext".into(),
3194                version: "1.0.0".into(),
3195                url: None,
3196            },
3197        };
3198        assert!(validate_artifact_spec(&spec).is_empty());
3199
3200        // Missing package name
3201        let bad = ArtifactSpec {
3202            source: ArtifactSource::Npm {
3203                package: String::new(),
3204                version: "1.0.0".into(),
3205                url: None,
3206            },
3207            ..spec
3208        };
3209        assert!(!validate_artifact_spec(&bad).is_empty());
3210    }
3211
3212    #[test]
3213    fn snapshot_validate_spec_url_source() {
3214        let spec = ArtifactSpec {
3215            id: "url-ext".into(),
3216            directory: "third-party/url-ext".into(),
3217            name: "URL Extension".into(),
3218            source_tier: SourceTier::ThirdPartyGithub,
3219            license: "Apache-2.0".into(),
3220            source: ArtifactSource::Url {
3221                url: "https://example.com/ext.ts".into(),
3222            },
3223        };
3224        assert!(validate_artifact_spec(&spec).is_empty());
3225    }
3226
3227    #[test]
3228    fn snapshot_digest_artifact_dir_deterministic() {
3229        let tmp = tempfile::tempdir().unwrap();
3230        std::fs::write(tmp.path().join("hello.ts"), b"console.log('hi');").unwrap();
3231        std::fs::write(tmp.path().join("index.ts"), b"export default function() {}").unwrap();
3232
3233        let d1 = snapshot::digest_artifact_dir(tmp.path()).unwrap();
3234        let d2 = snapshot::digest_artifact_dir(tmp.path()).unwrap();
3235        assert_eq!(d1, d2, "digest must be deterministic");
3236        assert_eq!(d1.len(), 64, "SHA-256 hex must be 64 chars");
3237    }
3238
3239    #[test]
3240    fn snapshot_digest_artifact_dir_changes_with_content() {
3241        let tmp = tempfile::tempdir().unwrap();
3242        std::fs::write(tmp.path().join("a.ts"), b"version1").unwrap();
3243        let d1 = snapshot::digest_artifact_dir(tmp.path()).unwrap();
3244
3245        std::fs::write(tmp.path().join("a.ts"), b"version2").unwrap();
3246        let d2 = snapshot::digest_artifact_dir(tmp.path()).unwrap();
3247
3248        assert_ne!(d1, d2, "different content must produce different digest");
3249    }
3250
3251    #[test]
3252    fn snapshot_verify_integrity_pass() {
3253        let tmp = tempfile::tempdir().unwrap();
3254        std::fs::write(tmp.path().join("test.ts"), b"hello").unwrap();
3255        let digest = snapshot::digest_artifact_dir(tmp.path()).unwrap();
3256
3257        let result = snapshot::verify_integrity(tmp.path(), &digest).unwrap();
3258        assert!(result.is_ok());
3259    }
3260
3261    #[test]
3262    fn snapshot_verify_integrity_fail() {
3263        let tmp = tempfile::tempdir().unwrap();
3264        std::fs::write(tmp.path().join("test.ts"), b"hello").unwrap();
3265
3266        let result = snapshot::verify_integrity(
3267            tmp.path(),
3268            "0000000000000000000000000000000000000000000000000000000000000000",
3269        )
3270        .unwrap();
3271        assert!(result.is_err());
3272        assert!(result.unwrap_err().contains("checksum mismatch"));
3273    }
3274
3275    #[test]
3276    fn snapshot_is_reserved_dir() {
3277        assert!(snapshot::is_reserved_dir("base_fixtures"));
3278        assert!(snapshot::is_reserved_dir("community"));
3279        assert!(snapshot::is_reserved_dir("plugins-official"));
3280        assert!(!snapshot::is_reserved_dir("hello"));
3281        assert!(!snapshot::is_reserved_dir("my-ext"));
3282    }
3283
3284    #[test]
3285    fn snapshot_source_tier_roundtrip_serde() {
3286        let tier = SourceTier::ThirdPartyGithub;
3287        let json = serde_json::to_string(&tier).unwrap();
3288        assert_eq!(json, "\"third-party-github\"");
3289        let parsed: SourceTier = serde_json::from_str(&json).unwrap();
3290        assert_eq!(parsed, tier);
3291    }
3292
3293    #[test]
3294    fn snapshot_artifact_spec_serde_roundtrip() {
3295        let spec = ArtifactSpec {
3296            id: "test-ext".into(),
3297            directory: "community/test-ext".into(),
3298            name: "Test".into(),
3299            source_tier: SourceTier::Community,
3300            license: "MIT".into(),
3301            source: ArtifactSource::Git {
3302                repo: "https://github.com/user/repo".into(),
3303                path: None,
3304                commit: Some("abc123".into()),
3305            },
3306        };
3307        let json = serde_json::to_string_pretty(&spec).unwrap();
3308        let parsed: ArtifactSpec = serde_json::from_str(&json).unwrap();
3309        assert_eq!(parsed.id, "test-ext");
3310        assert_eq!(parsed.source_tier, SourceTier::Community);
3311    }
3312
3313    // ════════════════════════════════════════════════════════════════════
3314    // Harness unit-test expansion: comparison functions (bd-k5q5.7.2)
3315    // ════════════════════════════════════════════════════════════════════
3316
3317    #[allow(clippy::needless_pass_by_value)]
3318    fn base_output(
3319        registrations: serde_json::Value,
3320        hostcall_log: serde_json::Value,
3321    ) -> serde_json::Value {
3322        json!({
3323            "extension_id": "ext",
3324            "name": "Ext",
3325            "version": "1.0.0",
3326            "registrations": registrations,
3327            "hostcall_log": hostcall_log
3328        })
3329    }
3330
3331    fn empty_registrations() -> serde_json::Value {
3332        json!({
3333            "commands": [],
3334            "shortcuts": [],
3335            "flags": [],
3336            "providers": [],
3337            "tool_defs": [],
3338            "models": [],
3339            "event_hooks": []
3340        })
3341    }
3342
3343    #[test]
3344    fn compare_detects_root_extension_id_mismatch() {
3345        let expected = base_output(empty_registrations(), json!([]));
3346        let mut actual = expected.clone();
3347        actual["extension_id"] = json!("other");
3348        let err = compare_conformance_output(&expected, &actual).unwrap_err();
3349        assert!(err.contains("ROOT"), "should report ROOT diff: {err}");
3350        assert!(err.contains("extension_id"), "should mention field: {err}");
3351    }
3352
3353    #[test]
3354    fn compare_detects_root_name_mismatch() {
3355        let expected = base_output(empty_registrations(), json!([]));
3356        let mut actual = expected.clone();
3357        actual["name"] = json!("Different");
3358        let err = compare_conformance_output(&expected, &actual).unwrap_err();
3359        assert!(err.contains("ROOT"), "diff kind: {err}");
3360    }
3361
3362    #[test]
3363    fn compare_detects_root_version_mismatch() {
3364        let expected = base_output(empty_registrations(), json!([]));
3365        let mut actual = expected.clone();
3366        actual["version"] = json!("2.0.0");
3367        let err = compare_conformance_output(&expected, &actual).unwrap_err();
3368        assert!(err.contains("version"), "diff: {err}");
3369    }
3370
3371    #[test]
3372    fn compare_detects_extra_registration_item() {
3373        let expected = base_output(
3374            json!({
3375                "commands": [{"name": "a", "description": "A"}],
3376                "shortcuts": [], "flags": [], "providers": [],
3377                "tool_defs": [], "models": [], "event_hooks": []
3378            }),
3379            json!([]),
3380        );
3381        let actual = base_output(
3382            json!({
3383                "commands": [
3384                    {"name": "a", "description": "A"},
3385                    {"name": "b", "description": "B"}
3386                ],
3387                "shortcuts": [], "flags": [], "providers": [],
3388                "tool_defs": [], "models": [], "event_hooks": []
3389            }),
3390            json!([]),
3391        );
3392        let err = compare_conformance_output(&expected, &actual).unwrap_err();
3393        assert!(err.contains("extra"), "should report extra: {err}");
3394        assert!(err.contains("name=b"), "should name the extra item: {err}");
3395    }
3396
3397    #[test]
3398    fn compare_detects_missing_registration_item() {
3399        let expected = base_output(
3400            json!({
3401                "commands": [
3402                    {"name": "a", "description": "A"},
3403                    {"name": "b", "description": "B"}
3404                ],
3405                "shortcuts": [], "flags": [], "providers": [],
3406                "tool_defs": [], "models": [], "event_hooks": []
3407            }),
3408            json!([]),
3409        );
3410        let actual = base_output(
3411            json!({
3412                "commands": [{"name": "a", "description": "A"}],
3413                "shortcuts": [], "flags": [], "providers": [],
3414                "tool_defs": [], "models": [], "event_hooks": []
3415            }),
3416            json!([]),
3417        );
3418        let err = compare_conformance_output(&expected, &actual).unwrap_err();
3419        assert!(err.contains("missing"), "should report missing: {err}");
3420    }
3421
3422    #[test]
3423    fn compare_detects_string_value_mismatch_in_registration() {
3424        let expected = base_output(
3425            json!({
3426                "commands": [{"name": "a", "description": "original"}],
3427                "shortcuts": [], "flags": [], "providers": [],
3428                "tool_defs": [], "models": [], "event_hooks": []
3429            }),
3430            json!([]),
3431        );
3432        let actual = base_output(
3433            json!({
3434                "commands": [{"name": "a", "description": "changed"}],
3435                "shortcuts": [], "flags": [], "providers": [],
3436                "tool_defs": [], "models": [], "event_hooks": []
3437            }),
3438            json!([]),
3439        );
3440        let err = compare_conformance_output(&expected, &actual).unwrap_err();
3441        assert!(err.contains("description"), "should identify field: {err}");
3442    }
3443
3444    #[test]
3445    fn compare_type_mismatch_produces_clear_diff() {
3446        let expected = base_output(
3447            json!({
3448                "commands": [{"name": "a", "value": "string"}],
3449                "shortcuts": [], "flags": [], "providers": [],
3450                "tool_defs": [], "models": [], "event_hooks": []
3451            }),
3452            json!([]),
3453        );
3454        let actual = base_output(
3455            json!({
3456                "commands": [{"name": "a", "value": 42}],
3457                "shortcuts": [], "flags": [], "providers": [],
3458                "tool_defs": [], "models": [], "event_hooks": []
3459            }),
3460            json!([]),
3461        );
3462        let err = compare_conformance_output(&expected, &actual).unwrap_err();
3463        assert!(
3464            err.contains("type mismatch"),
3465            "should report type mismatch: {err}"
3466        );
3467    }
3468
3469    #[test]
3470    fn compare_event_hooks_order_insensitive() {
3471        let expected = base_output(
3472            json!({
3473                "commands": [], "shortcuts": [], "flags": [],
3474                "providers": [], "tool_defs": [], "models": [],
3475                "event_hooks": ["on_tool", "on_message", "on_session"]
3476            }),
3477            json!([]),
3478        );
3479        let actual = base_output(
3480            json!({
3481                "commands": [], "shortcuts": [], "flags": [],
3482                "providers": [], "tool_defs": [], "models": [],
3483                "event_hooks": ["on_session", "on_tool", "on_message"]
3484            }),
3485            json!([]),
3486        );
3487        compare_conformance_output(&expected, &actual).unwrap();
3488    }
3489
3490    #[test]
3491    fn compare_event_hooks_detects_mismatch() {
3492        let expected = base_output(
3493            json!({
3494                "commands": [], "shortcuts": [], "flags": [],
3495                "providers": [], "tool_defs": [], "models": [],
3496                "event_hooks": ["on_tool", "on_message"]
3497            }),
3498            json!([]),
3499        );
3500        let actual = base_output(
3501            json!({
3502                "commands": [], "shortcuts": [], "flags": [],
3503                "providers": [], "tool_defs": [], "models": [],
3504                "event_hooks": ["on_tool", "on_session"]
3505            }),
3506            json!([]),
3507        );
3508        let err = compare_conformance_output(&expected, &actual).unwrap_err();
3509        assert!(
3510            err.contains("on_message") || err.contains("on_session"),
3511            "should report hook difference: {err}"
3512        );
3513    }
3514
3515    #[test]
3516    fn compare_empty_outputs_equal() {
3517        let a = base_output(empty_registrations(), json!([]));
3518        compare_conformance_output(&a, &a).unwrap();
3519    }
3520
3521    #[test]
3522    fn compare_null_registration_reports_error() {
3523        // When registrations is null, compare_registrations expects an
3524        // object and will report a diff — this verifies it doesn't panic.
3525        let expected = json!({
3526            "extension_id": "ext",
3527            "name": "Ext",
3528            "version": "1.0.0",
3529            "registrations": null,
3530            "hostcall_log": []
3531        });
3532        let actual = json!({
3533            "extension_id": "ext",
3534            "name": "Ext",
3535            "version": "1.0.0",
3536            "registrations": null,
3537            "hostcall_log": []
3538        });
3539        // Both null → both report "expected an object" → diffs cancel
3540        // Actually registrations expected=null, actual=null both fail as_object check.
3541        // The function pushes a diff for expected being non-object.
3542        let result = compare_conformance_output(&expected, &actual);
3543        assert!(
3544            result.is_err(),
3545            "null registrations should produce diff (expected object)"
3546        );
3547    }
3548
3549    #[test]
3550    fn compare_both_missing_registrations_reports_expected_object() {
3551        // When neither side has a "registrations" key, both resolve to
3552        // Null, and the function reports "expected an object" (the
3553        // contract requires registrations to be present).
3554        let expected = json!({
3555            "extension_id": "ext",
3556            "name": "Ext",
3557            "version": "1.0.0",
3558            "hostcall_log": []
3559        });
3560        let actual = expected.clone();
3561        let err = compare_conformance_output(&expected, &actual).unwrap_err();
3562        assert!(
3563            err.contains("expected an object"),
3564            "missing registrations should be flagged: {err}"
3565        );
3566    }
3567
3568    #[test]
3569    fn compare_hostcall_log_length_mismatch() {
3570        let expected = base_output(
3571            empty_registrations(),
3572            json!([
3573                {"op": "get_state", "result": {}},
3574                {"op": "set_name", "payload": {"name": "x"}}
3575            ]),
3576        );
3577        let actual = base_output(
3578            empty_registrations(),
3579            json!([{"op": "get_state", "result": {}}]),
3580        );
3581        let err = compare_conformance_output(&expected, &actual).unwrap_err();
3582        assert!(
3583            err.contains("length mismatch"),
3584            "should report length: {err}"
3585        );
3586    }
3587
3588    #[test]
3589    fn compare_float_within_epsilon_equal() {
3590        let expected = base_output(
3591            json!({
3592                "commands": [], "shortcuts": [], "flags": [],
3593                "providers": [], "models": [], "event_hooks": [],
3594                "tool_defs": [{"name": "t", "score": 0.1}]
3595            }),
3596            json!([]),
3597        );
3598        let actual = base_output(
3599            json!({
3600                "commands": [], "shortcuts": [], "flags": [],
3601                "providers": [], "models": [], "event_hooks": [],
3602                "tool_defs": [{"name": "t", "score": 0.100_000_000_000_001}]
3603            }),
3604            json!([]),
3605        );
3606        compare_conformance_output(&expected, &actual).unwrap();
3607    }
3608
3609    #[test]
3610    fn compare_float_beyond_epsilon_differs() {
3611        let expected = base_output(
3612            json!({
3613                "commands": [], "shortcuts": [], "flags": [],
3614                "providers": [], "models": [], "event_hooks": [],
3615                "tool_defs": [{"name": "t", "score": 0.1}]
3616            }),
3617            json!([]),
3618        );
3619        let actual = base_output(
3620            json!({
3621                "commands": [], "shortcuts": [], "flags": [],
3622                "providers": [], "models": [], "event_hooks": [],
3623                "tool_defs": [{"name": "t", "score": 0.2}]
3624            }),
3625            json!([]),
3626        );
3627        let err = compare_conformance_output(&expected, &actual).unwrap_err();
3628        assert!(err.contains("score"), "should mention score: {err}");
3629    }
3630
3631    #[test]
3632    fn compare_integer_exact_match() {
3633        let expected = base_output(
3634            json!({
3635                "commands": [], "shortcuts": [], "flags": [],
3636                "providers": [], "models": [], "event_hooks": [],
3637                "tool_defs": [{"name": "t", "count": 42}]
3638            }),
3639            json!([]),
3640        );
3641        let actual = base_output(
3642            json!({
3643                "commands": [], "shortcuts": [], "flags": [],
3644                "providers": [], "models": [], "event_hooks": [],
3645                "tool_defs": [{"name": "t", "count": 42}]
3646            }),
3647            json!([]),
3648        );
3649        compare_conformance_output(&expected, &actual).unwrap();
3650    }
3651
3652    #[test]
3653    fn compare_with_events_section() {
3654        let expected = json!({
3655            "extension_id": "ext",
3656            "name": "Ext",
3657            "version": "1.0.0",
3658            "registrations": {
3659                "commands": [], "shortcuts": [], "flags": [],
3660                "providers": [], "tool_defs": [], "models": [],
3661                "event_hooks": []
3662            },
3663            "hostcall_log": [],
3664            "events": { "count": 3, "types": ["start", "end"] }
3665        });
3666        let actual = json!({
3667            "extension_id": "ext",
3668            "name": "Ext",
3669            "version": "1.0.0",
3670            "registrations": {
3671                "commands": [], "shortcuts": [], "flags": [],
3672                "providers": [], "tool_defs": [], "models": [],
3673                "event_hooks": []
3674            },
3675            "hostcall_log": [],
3676            "events": { "count": 3, "types": ["start", "end"] }
3677        });
3678        compare_conformance_output(&expected, &actual).unwrap();
3679    }
3680
3681    #[test]
3682    fn compare_events_mismatch_detected() {
3683        let expected = json!({
3684            "extension_id": "ext",
3685            "name": "Ext",
3686            "version": "1.0.0",
3687            "registrations": {
3688                "commands": [], "shortcuts": [], "flags": [],
3689                "providers": [], "tool_defs": [], "models": [],
3690                "event_hooks": []
3691            },
3692            "hostcall_log": [],
3693            "events": { "count": 3 }
3694        });
3695        let actual = json!({
3696            "extension_id": "ext",
3697            "name": "Ext",
3698            "version": "1.0.0",
3699            "registrations": {
3700                "commands": [], "shortcuts": [], "flags": [],
3701                "providers": [], "tool_defs": [], "models": [],
3702                "event_hooks": []
3703            },
3704            "hostcall_log": [],
3705            "events": { "count": 5 }
3706        });
3707        let err = compare_conformance_output(&expected, &actual).unwrap_err();
3708        assert!(err.contains("EVENT"), "should be EVENT diff: {err}");
3709    }
3710
3711    #[test]
3712    fn compare_missing_events_both_sides_ok() {
3713        let a = base_output(empty_registrations(), json!([]));
3714        // Neither has events section → should pass
3715        compare_conformance_output(&a, &a).unwrap();
3716    }
3717
3718    #[test]
3719    fn compare_shortcuts_keyed_by_key_id() {
3720        let expected = base_output(
3721            json!({
3722                "commands": [], "flags": [], "providers": [],
3723                "tool_defs": [], "models": [], "event_hooks": [],
3724                "shortcuts": [
3725                    {"key_id": "ctrl+b", "label": "Bold"},
3726                    {"key_id": "ctrl+a", "label": "All"}
3727                ]
3728            }),
3729            json!([]),
3730        );
3731        let actual = base_output(
3732            json!({
3733                "commands": [], "flags": [], "providers": [],
3734                "tool_defs": [], "models": [], "event_hooks": [],
3735                "shortcuts": [
3736                    {"key_id": "ctrl+a", "label": "All"},
3737                    {"key_id": "ctrl+b", "label": "Bold"}
3738                ]
3739            }),
3740            json!([]),
3741        );
3742        // Order should not matter for shortcuts (keyed by key_id)
3743        compare_conformance_output(&expected, &actual).unwrap();
3744    }
3745
3746    #[test]
3747    fn compare_models_keyed_by_id() {
3748        let expected = base_output(
3749            json!({
3750                "commands": [], "shortcuts": [], "flags": [],
3751                "providers": [], "tool_defs": [], "event_hooks": [],
3752                "models": [
3753                    {"id": "m2", "name": "Model 2"},
3754                    {"id": "m1", "name": "Model 1"}
3755                ]
3756            }),
3757            json!([]),
3758        );
3759        let actual = base_output(
3760            json!({
3761                "commands": [], "shortcuts": [], "flags": [],
3762                "providers": [], "tool_defs": [], "event_hooks": [],
3763                "models": [
3764                    {"id": "m1", "name": "Model 1"},
3765                    {"id": "m2", "name": "Model 2"}
3766                ]
3767            }),
3768            json!([]),
3769        );
3770        compare_conformance_output(&expected, &actual).unwrap();
3771    }
3772
3773    #[test]
3774    fn compare_models_with_duplicate_ids_is_reflexive() {
3775        // Regression fixture from a shrunk proptest case: duplicate model IDs
3776        // should not make comparator fail on self-compare.
3777        let sample = json!({
3778            "extension_id": "a",
3779            "name": "_",
3780            "version": "0.0.0",
3781            "registrations": {
3782                "commands": [],
3783                "event_hooks": [],
3784                "flags": [],
3785                "models": [
3786                    {"id": "_", "name": "model-_"},
3787                    {"id": "_", "name": "model-_"}
3788                ],
3789                "providers": [],
3790                "shortcuts": [],
3791                "tool_defs": []
3792            },
3793            "hostcall_log": []
3794        });
3795        compare_conformance_output(&sample, &sample).unwrap();
3796    }
3797
3798    #[test]
3799    fn report_empty_results() {
3800        let report = generate_report(
3801            "run-empty",
3802            Some("2026-02-07T00:00:00Z".to_string()),
3803            vec![],
3804        );
3805        assert_eq!(report.summary.total, 0);
3806        assert_eq!(report.summary.passed, 0);
3807        assert!(report.summary.pass_rate.abs() < f64::EPSILON);
3808        assert!(report.summary.by_tier.is_empty());
3809    }
3810
3811    #[test]
3812    fn report_all_pass() {
3813        let results = vec![
3814            ExtensionConformanceResult {
3815                id: "a".into(),
3816                tier: Some(1),
3817                status: ConformanceStatus::Pass,
3818                ts_time_ms: Some(10),
3819                rust_time_ms: Some(8),
3820                diffs: vec![],
3821                notes: None,
3822            },
3823            ExtensionConformanceResult {
3824                id: "b".into(),
3825                tier: Some(1),
3826                status: ConformanceStatus::Pass,
3827                ts_time_ms: Some(20),
3828                rust_time_ms: Some(15),
3829                diffs: vec![],
3830                notes: None,
3831            },
3832        ];
3833        let report = generate_report(
3834            "run-pass",
3835            Some("2026-02-07T00:00:00Z".to_string()),
3836            results,
3837        );
3838        assert_eq!(report.summary.total, 2);
3839        assert_eq!(report.summary.passed, 2);
3840        assert!((report.summary.pass_rate - 1.0).abs() < f64::EPSILON);
3841    }
3842
3843    #[test]
3844    fn regression_no_overlap_flags_missing_extension() {
3845        // When a previously-passing extension disappears from current,
3846        // compute_regression treats it as a regression (Pass → None).
3847        let previous = generate_report(
3848            "prev",
3849            Some("2026-02-05T00:00:00Z".to_string()),
3850            vec![ExtensionConformanceResult {
3851                id: "old-ext".into(),
3852                tier: Some(1),
3853                status: ConformanceStatus::Pass,
3854                ts_time_ms: None,
3855                rust_time_ms: None,
3856                diffs: vec![],
3857                notes: None,
3858            }],
3859        );
3860        let current = generate_report(
3861            "cur",
3862            Some("2026-02-06T00:00:00Z".to_string()),
3863            vec![ExtensionConformanceResult {
3864                id: "new-ext".into(),
3865                tier: Some(1),
3866                status: ConformanceStatus::Fail,
3867                ts_time_ms: None,
3868                rust_time_ms: None,
3869                diffs: vec![],
3870                notes: None,
3871            }],
3872        );
3873        let regression = compute_regression(&previous, &current);
3874        // compared_total is based on previous.extensions.len() = 1
3875        assert_eq!(regression.compared_total, 1);
3876        // old-ext was Pass but is now absent → counted as regression
3877        assert!(regression.has_regression());
3878        assert_eq!(regression.regressed_extensions.len(), 1);
3879        assert_eq!(regression.regressed_extensions[0].id, "old-ext");
3880        assert_eq!(regression.regressed_extensions[0].current, None);
3881    }
3882
3883    #[test]
3884    fn regression_all_passing_to_passing() {
3885        let results = vec![ExtensionConformanceResult {
3886            id: "a".into(),
3887            tier: Some(1),
3888            status: ConformanceStatus::Pass,
3889            ts_time_ms: None,
3890            rust_time_ms: None,
3891            diffs: vec![],
3892            notes: None,
3893        }];
3894        let previous = generate_report(
3895            "prev",
3896            Some("2026-02-05T00:00:00Z".to_string()),
3897            results.clone(),
3898        );
3899        let current = generate_report("cur", Some("2026-02-06T00:00:00Z".to_string()), results);
3900        let regression = compute_regression(&previous, &current);
3901        assert!(!regression.has_regression());
3902        assert_eq!(regression.compared_total, 1);
3903        assert!((regression.pass_rate_delta).abs() < f64::EPSILON);
3904    }
3905
3906    #[test]
3907    fn conformance_status_as_upper_str() {
3908        assert_eq!(ConformanceStatus::Pass.as_upper_str(), "PASS");
3909        assert_eq!(ConformanceStatus::Fail.as_upper_str(), "FAIL");
3910        assert_eq!(ConformanceStatus::Skip.as_upper_str(), "SKIP");
3911        assert_eq!(ConformanceStatus::Error.as_upper_str(), "ERROR");
3912    }
3913
3914    fn ident_strategy() -> impl Strategy<Value = String> {
3915        string_regex("[a-z0-9_-]{1,16}").expect("valid identifier regex")
3916    }
3917
3918    fn semver_strategy() -> impl Strategy<Value = String> {
3919        (0u8..10, 0u8..20, 0u8..20)
3920            .prop_map(|(major, minor, patch)| format!("{major}.{minor}.{patch}"))
3921    }
3922
3923    fn bounded_json(max_depth: u32) -> BoxedStrategy<Value> {
3924        let leaf = prop_oneof![
3925            Just(Value::Null),
3926            any::<bool>().prop_map(Value::Bool),
3927            any::<i64>().prop_map(|n| Value::Number(n.into())),
3928            string_regex("[A-Za-z0-9 _.-]{0,32}")
3929                .expect("valid scalar string regex")
3930                .prop_map(Value::String),
3931        ];
3932
3933        if max_depth == 0 {
3934            return leaf.boxed();
3935        }
3936
3937        let array_strategy =
3938            prop::collection::vec(bounded_json(max_depth - 1), 0..4).prop_map(Value::Array);
3939        let object_strategy = prop::collection::btree_map(
3940            string_regex("[a-z]{1,8}").expect("valid object key regex"),
3941            bounded_json(max_depth - 1),
3942            0..4,
3943        )
3944        .prop_map(|map| Value::Object(map.into_iter().collect::<Map<String, Value>>()));
3945
3946        prop_oneof![leaf, array_strategy, object_strategy].boxed()
3947    }
3948
3949    fn named_entry_strategy() -> BoxedStrategy<Value> {
3950        (
3951            ident_strategy(),
3952            string_regex("[A-Za-z0-9 _.-]{0,24}").expect("valid description regex"),
3953        )
3954            .prop_map(|(name, description)| json!({ "name": name, "description": description }))
3955            .boxed()
3956    }
3957
3958    fn shortcut_entry_strategy() -> BoxedStrategy<Value> {
3959        (
3960            ident_strategy(),
3961            string_regex("[A-Za-z0-9 _.-]{0,24}").expect("valid shortcut description regex"),
3962        )
3963            .prop_map(
3964                |(key_id, description)| json!({ "key_id": key_id, "description": description }),
3965            )
3966            .boxed()
3967    }
3968
3969    fn model_entry_strategy() -> BoxedStrategy<Value> {
3970        ident_strategy()
3971            .prop_map(|id| json!({ "id": id, "name": format!("model-{id}") }))
3972            .boxed()
3973    }
3974
3975    fn tool_def_entry_strategy() -> BoxedStrategy<Value> {
3976        (
3977            ident_strategy(),
3978            prop::collection::vec(ident_strategy(), 0..6),
3979            bounded_json(1),
3980        )
3981            .prop_map(|(name, required, input)| {
3982                json!({
3983                    "name": name,
3984                    "parameters": {
3985                        "type": "object",
3986                        "required": required,
3987                        "input": [input]
3988                    }
3989                })
3990            })
3991            .boxed()
3992    }
3993
3994    fn hostcall_entry_strategy() -> BoxedStrategy<Value> {
3995        (ident_strategy(), bounded_json(2))
3996            .prop_map(|(op, payload)| json!({ "op": op, "payload": payload }))
3997            .boxed()
3998    }
3999
4000    /// Dedup a vec of JSON objects by a string key field, keeping the first
4001    /// occurrence of each key. This ensures generated registration lists have
4002    /// unique keys, matching the comparator's expectations.
4003    fn dedup_by_key(items: Vec<Value>, key_field: &str) -> Vec<Value> {
4004        let mut seen = BTreeSet::new();
4005        items
4006            .into_iter()
4007            .filter(|item| {
4008                item.get(key_field)
4009                    .and_then(Value::as_str)
4010                    .is_none_or(|k| seen.insert(k.to_string()))
4011            })
4012            .collect()
4013    }
4014
4015    fn conformance_output_strategy() -> impl Strategy<Value = Value> {
4016        (
4017            ident_strategy(),
4018            ident_strategy(),
4019            semver_strategy(),
4020            prop::collection::vec(named_entry_strategy(), 0..6),
4021            prop::collection::vec(shortcut_entry_strategy(), 0..6),
4022            prop::collection::vec(named_entry_strategy(), 0..6),
4023            prop::collection::vec(named_entry_strategy(), 0..6),
4024            prop::collection::vec(tool_def_entry_strategy(), 0..6),
4025            prop::collection::vec(model_entry_strategy(), 0..6),
4026            prop::collection::vec(ident_strategy(), 0..6),
4027            prop::collection::vec(hostcall_entry_strategy(), 0..8),
4028            prop::option::of(bounded_json(3)),
4029        )
4030            .prop_map(
4031                |(
4032                    extension_id,
4033                    name,
4034                    version,
4035                    commands,
4036                    shortcuts,
4037                    flags,
4038                    providers,
4039                    tool_defs,
4040                    models,
4041                    event_hooks,
4042                    hostcall_log,
4043                    events,
4044                )| {
4045                    let mut out = json!({
4046                        "extension_id": extension_id,
4047                        "name": name,
4048                        "version": version,
4049                        "registrations": {
4050                            "commands": dedup_by_key(commands, "name"),
4051                            "shortcuts": dedup_by_key(shortcuts, "key_id"),
4052                            "flags": dedup_by_key(flags, "name"),
4053                            "providers": dedup_by_key(providers, "name"),
4054                            "tool_defs": dedup_by_key(tool_defs, "name"),
4055                            "models": dedup_by_key(models, "id"),
4056                            "event_hooks": event_hooks
4057                        },
4058                        "hostcall_log": hostcall_log
4059                    });
4060                    if let Some(events) = events {
4061                        out.as_object_mut()
4062                            .expect("root object")
4063                            .insert("events".to_string(), events);
4064                    }
4065                    out
4066                },
4067            )
4068    }
4069
4070    fn minimal_output_with_events(events: &Value) -> Value {
4071        json!({
4072            "extension_id": "ext",
4073            "name": "Ext",
4074            "version": "1.0.0",
4075            "registrations": {
4076                "commands": [],
4077                "shortcuts": [],
4078                "flags": [],
4079                "providers": [],
4080                "tool_defs": [],
4081                "models": [],
4082                "event_hooks": []
4083            },
4084            "hostcall_log": [],
4085            "events": events
4086        })
4087    }
4088
4089    fn output_with_type_probe(value: &Value) -> Value {
4090        json!({
4091            "extension_id": "ext",
4092            "name": "Ext",
4093            "version": "1.0.0",
4094            "registrations": {
4095                "commands": [],
4096                "shortcuts": [],
4097                "flags": [],
4098                "providers": [],
4099                "tool_defs": [{ "name": "probe", "parameters": { "value": value } }],
4100                "models": [],
4101                "event_hooks": []
4102            },
4103            "hostcall_log": []
4104        })
4105    }
4106
4107    fn deeply_nested_object(depth: usize, leaf: Value) -> Value {
4108        let mut current = leaf;
4109        for idx in 0..depth {
4110            let mut map = Map::new();
4111            map.insert(format!("k{idx}"), current);
4112            current = Value::Object(map);
4113        }
4114        current
4115    }
4116
4117    fn primitive_value_strategy() -> impl Strategy<Value = Value> {
4118        prop_oneof![
4119            Just(Value::Null),
4120            any::<bool>().prop_map(Value::Bool),
4121            any::<i64>().prop_map(|n| Value::Number(n.into())),
4122            string_regex("[A-Za-z0-9 _.-]{0,20}")
4123                .expect("valid primitive string regex")
4124                .prop_map(Value::String),
4125            prop::collection::vec(any::<u8>(), 0..4).prop_map(|bytes| {
4126                Value::Array(
4127                    bytes
4128                        .into_iter()
4129                        .map(|b| Value::Number(u64::from(b).into()))
4130                        .collect(),
4131                )
4132            }),
4133            prop::collection::btree_map(
4134                string_regex("[a-z]{1,4}").expect("valid primitive object key regex"),
4135                any::<u8>(),
4136                0..3
4137            )
4138            .prop_map(|entries| {
4139                let mut map = Map::new();
4140                for (key, value) in entries {
4141                    map.insert(key, Value::Number(u64::from(value).into()));
4142                }
4143                Value::Object(map)
4144            }),
4145        ]
4146    }
4147
4148    proptest! {
4149        #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
4150
4151        #[test]
4152        fn proptest_compare_conformance_output_reflexive(
4153            sample in conformance_output_strategy()
4154        ) {
4155            prop_assert!(
4156                compare_conformance_output(&sample, &sample).is_ok(),
4157                "comparator should be reflexive on valid conformance shape"
4158            );
4159        }
4160
4161        #[test]
4162        fn proptest_compare_conformance_output_symmetry(
4163            expected in conformance_output_strategy(),
4164            actual in conformance_output_strategy()
4165        ) {
4166            let left = compare_conformance_output(&expected, &actual).is_ok();
4167            let right = compare_conformance_output(&actual, &expected).is_ok();
4168            prop_assert_eq!(left, right);
4169        }
4170
4171        #[test]
4172        fn proptest_compare_deep_nesting_depth_200_no_panic(
4173            leaf in bounded_json(1)
4174        ) {
4175            let nested = deeply_nested_object(200, leaf);
4176            let expected = minimal_output_with_events(&nested);
4177            let actual = minimal_output_with_events(&nested);
4178            prop_assert!(compare_conformance_output(&expected, &actual).is_ok());
4179        }
4180
4181        #[test]
4182        fn proptest_compare_large_required_arrays_order_insensitive(
4183            required in prop::collection::btree_set(ident_strategy(), 0..256)
4184        ) {
4185            let required_vec = required.into_iter().collect::<Vec<_>>();
4186            let mut reversed = required_vec.clone();
4187            reversed.reverse();
4188
4189            let expected = json!({
4190                "extension_id": "ext",
4191                "name": "Ext",
4192                "version": "1.0.0",
4193                "registrations": {
4194                    "commands": [],
4195                    "shortcuts": [],
4196                    "flags": [],
4197                    "providers": [],
4198                    "tool_defs": [{ "name": "t", "parameters": { "required": required_vec } }],
4199                    "models": [],
4200                    "event_hooks": []
4201                },
4202                "hostcall_log": []
4203            });
4204            let actual = json!({
4205                "extension_id": "ext",
4206                "name": "Ext",
4207                "version": "1.0.0",
4208                "registrations": {
4209                    "commands": [],
4210                    "shortcuts": [],
4211                    "flags": [],
4212                    "providers": [],
4213                    "tool_defs": [{ "name": "t", "parameters": { "required": reversed } }],
4214                    "models": [],
4215                    "event_hooks": []
4216                },
4217                "hostcall_log": []
4218            });
4219
4220            prop_assert!(compare_conformance_output(&expected, &actual).is_ok());
4221        }
4222
4223        #[test]
4224        fn proptest_type_confusion_reports_diff(
4225            left in primitive_value_strategy(),
4226            right in primitive_value_strategy()
4227        ) {
4228            prop_assume!(super::json_type_name(&left) != super::json_type_name(&right));
4229            let expected = output_with_type_probe(&left);
4230            let actual = output_with_type_probe(&right);
4231            prop_assert!(compare_conformance_output(&expected, &actual).is_err());
4232        }
4233    }
4234}