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