Skip to main content

pi/
conformance_shapes.rs

1//! Shape-aware conformance harness for extension types.
2//!
3//! This module provides a unified harness that can load, exercise, and validate
4//! each extension shape (tool, command, provider, event hook, UI component,
5//! configuration, multi, general) through standardized lifecycle hooks:
6//!
7//! 1. **Load** — Parse and load the extension into the QuickJS runtime.
8//! 2. **Verify registrations** — Check that expected registration types appeared.
9//! 3. **Invoke** — Exercise the extension's primary dispatch path (tool call,
10//!    command execution, event dispatch, etc.).
11//! 4. **Shutdown** — Cleanly tear down the runtime and verify no panics or leaks.
12//!
13//! Each lifecycle step emits structured JSONL events for diagnostics.
14//! Error reporting classifies failures into actionable categories.
15
16use crate::extension_conformance_matrix::HostCapability;
17use crate::extension_inclusion::ExtensionCategory;
18use chrono::{SecondsFormat, Utc};
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use std::collections::BTreeMap;
22use std::fmt;
23use std::path::{Path, PathBuf};
24
25// ────────────────────────────────────────────────────────────────────────────
26// Extension shape descriptor
27// ────────────────────────────────────────────────────────────────────────────
28
29/// Maps 1:1 to `ExtensionCategory` but is specialized for harness dispatch.
30///
31/// Each variant knows what registration types to expect and what invocation
32/// protocol to use.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ExtensionShape {
36    Tool,
37    Command,
38    Provider,
39    EventHook,
40    UiComponent,
41    Configuration,
42    Multi,
43    General,
44}
45
46impl ExtensionShape {
47    /// Convert from the inclusion-list category.
48    #[must_use]
49    pub const fn from_category(cat: &ExtensionCategory) -> Self {
50        match cat {
51            ExtensionCategory::Tool => Self::Tool,
52            ExtensionCategory::Command => Self::Command,
53            ExtensionCategory::Provider => Self::Provider,
54            ExtensionCategory::EventHook => Self::EventHook,
55            ExtensionCategory::UiComponent => Self::UiComponent,
56            ExtensionCategory::Configuration => Self::Configuration,
57            ExtensionCategory::Multi => Self::Multi,
58            ExtensionCategory::General => Self::General,
59        }
60    }
61
62    /// All shapes in canonical order.
63    #[must_use]
64    pub const fn all() -> &'static [Self] {
65        &[
66            Self::Tool,
67            Self::Command,
68            Self::Provider,
69            Self::EventHook,
70            Self::UiComponent,
71            Self::Configuration,
72            Self::Multi,
73            Self::General,
74        ]
75    }
76
77    /// Registration fields that MUST be non-empty after loading.
78    ///
79    /// Returns the JSON field names in the `RegisterPayload` that this shape
80    /// is expected to populate.  For `Multi` and `General`, returns an empty
81    /// slice (checked separately).
82    #[must_use]
83    pub const fn expected_registration_fields(&self) -> &'static [&'static str] {
84        match self {
85            Self::Tool => &["tools"],
86            Self::Command => &["slash_commands"],
87            Self::Provider => &["providers"],
88            Self::EventHook => &["event_hooks"],
89            Self::UiComponent => &["message_renderers"],
90            Self::Configuration => &["flags", "shortcuts"],
91            Self::Multi | Self::General => &[],
92        }
93    }
94
95    /// Whether this shape supports runtime invocation (beyond registration).
96    #[must_use]
97    pub const fn supports_invocation(&self) -> bool {
98        matches!(
99            self,
100            Self::Tool | Self::Command | Self::EventHook | Self::Multi
101        )
102    }
103
104    /// The `HostCapability` values typically exercised by this shape.
105    #[must_use]
106    pub fn typical_capabilities(&self) -> Vec<HostCapability> {
107        match self {
108            Self::Tool => vec![
109                HostCapability::Read,
110                HostCapability::Write,
111                HostCapability::Exec,
112                HostCapability::Tool,
113            ],
114            Self::Command => vec![HostCapability::Session, HostCapability::Ui],
115            Self::Provider => vec![HostCapability::Http, HostCapability::Env],
116            Self::EventHook => vec![
117                HostCapability::Session,
118                HostCapability::Ui,
119                HostCapability::Exec,
120            ],
121            Self::UiComponent => vec![HostCapability::Ui],
122            Self::Configuration => vec![HostCapability::Env],
123            Self::Multi => vec![HostCapability::Session, HostCapability::Tool],
124            Self::General => vec![HostCapability::Log],
125        }
126    }
127}
128
129impl fmt::Display for ExtensionShape {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            Self::Tool => write!(f, "tool"),
133            Self::Command => write!(f, "command"),
134            Self::Provider => write!(f, "provider"),
135            Self::EventHook => write!(f, "event_hook"),
136            Self::UiComponent => write!(f, "ui_component"),
137            Self::Configuration => write!(f, "configuration"),
138            Self::Multi => write!(f, "multi"),
139            Self::General => write!(f, "general"),
140        }
141    }
142}
143
144// ────────────────────────────────────────────────────────────────────────────
145// Error classification
146// ────────────────────────────────────────────────────────────────────────────
147
148/// Failure category for conformance diagnostics.
149///
150/// Each variant maps to a human-readable explanation and a remediation hint.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum FailureClass {
154    /// Extension file could not be parsed or loaded.
155    LoadError,
156    /// Expected registration type was missing after load.
157    MissingRegistration,
158    /// Registration was present but structurally invalid.
159    MalformedRegistration,
160    /// Tool/command/event invocation returned an error.
161    InvocationError,
162    /// Invocation result did not match expectations.
163    OutputMismatch,
164    /// Extension timed out during load or invocation.
165    Timeout,
166    /// Extension shape is incompatible with the requested operation.
167    IncompatibleShape,
168    /// Shutdown did not complete cleanly.
169    ShutdownError,
170    /// QuickJS runtime or shim gap.
171    RuntimeShimGap,
172}
173
174impl fmt::Display for FailureClass {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        match self {
177            Self::LoadError => write!(f, "load_error"),
178            Self::MissingRegistration => write!(f, "missing_registration"),
179            Self::MalformedRegistration => write!(f, "malformed_registration"),
180            Self::InvocationError => write!(f, "invocation_error"),
181            Self::OutputMismatch => write!(f, "output_mismatch"),
182            Self::Timeout => write!(f, "timeout"),
183            Self::IncompatibleShape => write!(f, "incompatible_shape"),
184            Self::ShutdownError => write!(f, "shutdown_error"),
185            Self::RuntimeShimGap => write!(f, "runtime_shim_gap"),
186        }
187    }
188}
189
190/// A classified conformance failure with context.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct ShapeFailure {
193    pub class: FailureClass,
194    pub message: String,
195    /// JSON path or field where the failure was detected (if applicable).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub path: Option<String>,
198    /// Remediation hint for the developer.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub hint: Option<String>,
201}
202
203impl ShapeFailure {
204    #[must_use]
205    pub fn new(class: FailureClass, message: impl Into<String>) -> Self {
206        Self {
207            class,
208            message: message.into(),
209            path: None,
210            hint: None,
211        }
212    }
213
214    #[must_use]
215    pub fn with_path(mut self, path: impl Into<String>) -> Self {
216        self.path = Some(path.into());
217        self
218    }
219
220    #[must_use]
221    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
222        self.hint = Some(hint.into());
223        self
224    }
225}
226
227impl fmt::Display for ShapeFailure {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        write!(f, "[{}] {}", self.class, self.message)?;
230        if let Some(path) = &self.path {
231            write!(f, " (at {path})")?;
232        }
233        if let Some(hint) = &self.hint {
234            write!(f, " — hint: {hint}")?;
235        }
236        Ok(())
237    }
238}
239
240// ────────────────────────────────────────────────────────────────────────────
241// JSONL event logging
242// ────────────────────────────────────────────────────────────────────────────
243
244/// Lifecycle phase for JSONL event tagging.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case")]
247pub enum LifecyclePhase {
248    Load,
249    VerifyRegistrations,
250    Invoke,
251    Shutdown,
252}
253
254impl fmt::Display for LifecyclePhase {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        match self {
257            Self::Load => write!(f, "load"),
258            Self::VerifyRegistrations => write!(f, "verify_registrations"),
259            Self::Invoke => write!(f, "invoke"),
260            Self::Shutdown => write!(f, "shutdown"),
261        }
262    }
263}
264
265/// Structured JSONL event emitted by the harness.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct ShapeEvent {
268    pub timestamp: String,
269    pub correlation_id: String,
270    pub extension_id: String,
271    pub shape: ExtensionShape,
272    pub phase: LifecyclePhase,
273    pub status: ShapeEventStatus,
274    pub duration_ms: u64,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub details: Option<Value>,
277    #[serde(default, skip_serializing_if = "Vec::is_empty")]
278    pub failures: Vec<ShapeFailure>,
279}
280
281/// Status of a lifecycle event.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "snake_case")]
284pub enum ShapeEventStatus {
285    Ok,
286    Fail,
287    Skip,
288}
289
290impl ShapeEvent {
291    /// Create a new event with the current timestamp.
292    pub fn new(
293        correlation_id: &str,
294        extension_id: &str,
295        shape: ExtensionShape,
296        phase: LifecyclePhase,
297    ) -> Self {
298        Self {
299            timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
300            correlation_id: correlation_id.to_string(),
301            extension_id: extension_id.to_string(),
302            shape,
303            phase,
304            status: ShapeEventStatus::Ok,
305            duration_ms: 0,
306            details: None,
307            failures: Vec::new(),
308        }
309    }
310
311    /// Serialize to a single JSONL line (no trailing newline).
312    #[must_use]
313    pub fn to_jsonl(&self) -> String {
314        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
315    }
316}
317
318// ────────────────────────────────────────────────────────────────────────────
319// Registration verification
320// ────────────────────────────────────────────────────────────────────────────
321
322/// Registration snapshot extracted after extension load.
323///
324/// This is the subset of `RegisterPayload` fields needed for shape verification.
325#[derive(Debug, Clone, Default, Serialize, Deserialize)]
326pub struct RegistrationSnapshot {
327    #[serde(default)]
328    pub tools: Vec<Value>,
329    #[serde(default)]
330    pub slash_commands: Vec<Value>,
331    #[serde(default)]
332    pub shortcuts: Vec<Value>,
333    #[serde(default)]
334    pub flags: Vec<Value>,
335    #[serde(default)]
336    pub event_hooks: Vec<String>,
337    #[serde(default)]
338    pub providers: Vec<Value>,
339    #[serde(default)]
340    pub models: Vec<Value>,
341    #[serde(default)]
342    pub message_renderers: Vec<Value>,
343}
344
345impl RegistrationSnapshot {
346    /// Get the count of a registration field by name.
347    #[must_use]
348    pub fn field_count(&self, field: &str) -> usize {
349        match field {
350            "tools" => self.tools.len(),
351            "slash_commands" => self.slash_commands.len(),
352            "shortcuts" => self.shortcuts.len(),
353            "flags" => self.flags.len(),
354            "event_hooks" => self.event_hooks.len(),
355            "providers" => self.providers.len(),
356            "models" => self.models.len(),
357            "message_renderers" => self.message_renderers.len(),
358            _ => 0,
359        }
360    }
361
362    /// Total number of registrations across all fields.
363    #[must_use]
364    pub fn total_registrations(&self) -> usize {
365        self.tools.len()
366            + self.slash_commands.len()
367            + self.shortcuts.len()
368            + self.flags.len()
369            + self.event_hooks.len()
370            + self.providers.len()
371            + self.models.len()
372            + self.message_renderers.len()
373    }
374
375    /// Classify the shape based on what was actually registered.
376    #[must_use]
377    pub fn detected_shape(&self) -> ExtensionShape {
378        let mut types = Vec::new();
379        if !self.tools.is_empty() {
380            types.push("tool");
381        }
382        if !self.slash_commands.is_empty() {
383            types.push("command");
384        }
385        if !self.providers.is_empty() || !self.models.is_empty() {
386            types.push("provider");
387        }
388        if !self.event_hooks.is_empty() {
389            types.push("event_hook");
390        }
391        if !self.message_renderers.is_empty() {
392            types.push("ui_component");
393        }
394        if !self.flags.is_empty() || !self.shortcuts.is_empty() {
395            types.push("configuration");
396        }
397
398        match types.len() {
399            0 => ExtensionShape::General,
400            1 => match types[0] {
401                "tool" => ExtensionShape::Tool,
402                "command" => ExtensionShape::Command,
403                "provider" => ExtensionShape::Provider,
404                "event_hook" => ExtensionShape::EventHook,
405                "ui_component" => ExtensionShape::UiComponent,
406                "configuration" => ExtensionShape::Configuration,
407                _ => ExtensionShape::General,
408            },
409            _ => ExtensionShape::Multi,
410        }
411    }
412}
413
414/// Verify that a registration snapshot matches the expected shape.
415///
416/// Returns a list of failures (empty means verification passed).
417#[must_use]
418#[allow(clippy::too_many_lines)]
419pub fn verify_registrations(
420    shape: ExtensionShape,
421    snapshot: &RegistrationSnapshot,
422) -> Vec<ShapeFailure> {
423    let mut failures = Vec::new();
424
425    match shape {
426        ExtensionShape::Tool => {
427            if snapshot.tools.is_empty() {
428                failures.push(
429                    ShapeFailure::new(
430                        FailureClass::MissingRegistration,
431                        "Tool extension must register at least one tool via registerTool()",
432                    )
433                    .with_path("registrations.tools")
434                    .with_hint("Call pi.registerTool({name, description, parameters, handler})"),
435                );
436            }
437            // Validate each tool has required fields
438            for (idx, tool) in snapshot.tools.iter().enumerate() {
439                if tool.get("name").and_then(Value::as_str).is_none() {
440                    failures.push(
441                        ShapeFailure::new(
442                            FailureClass::MalformedRegistration,
443                            format!("Tool [{idx}] missing 'name' field"),
444                        )
445                        .with_path(format!("registrations.tools[{idx}].name")),
446                    );
447                }
448            }
449        }
450        ExtensionShape::Command => {
451            if snapshot.slash_commands.is_empty() {
452                failures.push(
453                    ShapeFailure::new(
454                        FailureClass::MissingRegistration,
455                        "Command extension must register at least one slash command",
456                    )
457                    .with_path("registrations.slash_commands")
458                    .with_hint("Call pi.registerCommand(name, {description, handler})"),
459                );
460            }
461        }
462        ExtensionShape::Provider => {
463            if snapshot.providers.is_empty() && snapshot.models.is_empty() {
464                failures.push(
465                    ShapeFailure::new(
466                        FailureClass::MissingRegistration,
467                        "Provider extension must register at least one provider or model",
468                    )
469                    .with_path("registrations.providers")
470                    .with_hint("Call pi.registerProvider(name, {api, baseUrl, models})"),
471                );
472            }
473        }
474        ExtensionShape::EventHook => {
475            if snapshot.event_hooks.is_empty() {
476                failures.push(
477                    ShapeFailure::new(
478                        FailureClass::MissingRegistration,
479                        "EventHook extension must register at least one event listener",
480                    )
481                    .with_path("registrations.event_hooks")
482                    .with_hint("Call pi.on(eventName, handler)"),
483                );
484            }
485        }
486        ExtensionShape::UiComponent => {
487            if snapshot.message_renderers.is_empty() {
488                failures.push(
489                    ShapeFailure::new(
490                        FailureClass::MissingRegistration,
491                        "UiComponent extension must register at least one message renderer",
492                    )
493                    .with_path("registrations.message_renderers")
494                    .with_hint("Call pi.registerMessageRenderer({contentType, render})"),
495                );
496            }
497        }
498        ExtensionShape::Configuration => {
499            if snapshot.flags.is_empty() && snapshot.shortcuts.is_empty() {
500                failures.push(
501                    ShapeFailure::new(
502                        FailureClass::MissingRegistration,
503                        "Configuration extension must register at least one flag or shortcut",
504                    )
505                    .with_path("registrations.flags|shortcuts")
506                    .with_hint("Call pi.registerFlag(spec) or pi.registerShortcut(spec)"),
507                );
508            }
509        }
510        ExtensionShape::Multi => {
511            // Multi extensions should have at least 2 distinct registration types
512            let distinct_types = [
513                !snapshot.tools.is_empty(),
514                !snapshot.slash_commands.is_empty(),
515                !snapshot.providers.is_empty() || !snapshot.models.is_empty(),
516                !snapshot.event_hooks.is_empty(),
517                !snapshot.message_renderers.is_empty(),
518                !snapshot.flags.is_empty() || !snapshot.shortcuts.is_empty(),
519            ]
520            .iter()
521            .filter(|&&present| present)
522            .count();
523
524            if distinct_types < 2 {
525                failures.push(
526                    ShapeFailure::new(
527                        FailureClass::MissingRegistration,
528                        format!(
529                            "Multi extension should register 2+ distinct types, found {distinct_types}"
530                        ),
531                    )
532                    .with_hint("Register combinations like tool+event_hook or command+flag"),
533                );
534            }
535        }
536        ExtensionShape::General => {
537            // General extensions have no required registrations.
538            // But they should at least load without error (checked elsewhere).
539        }
540    }
541
542    failures
543}
544
545// ────────────────────────────────────────────────────────────────────────────
546// Invocation descriptors
547// ────────────────────────────────────────────────────────────────────────────
548
549/// What to invoke after loading an extension.
550///
551/// Each variant carries the minimum data needed to exercise that shape's
552/// primary dispatch path.
553#[derive(Debug, Clone, Serialize, Deserialize)]
554#[serde(tag = "kind", rename_all = "snake_case")]
555pub enum ShapeInvocation {
556    /// Call a registered tool by name.
557    ToolCall { tool_name: String, arguments: Value },
558    /// Execute a slash command.
559    CommandExec {
560        command_name: String,
561        #[serde(default)]
562        args: String,
563    },
564    /// Dispatch an event to registered hooks.
565    EventDispatch {
566        event_name: String,
567        #[serde(default)]
568        payload: Value,
569    },
570    /// Verify provider registration (no runtime call needed).
571    ProviderCheck,
572    /// Verify UI component registration (no runtime call needed).
573    UiComponentCheck,
574    /// Verify flag/shortcut registration (no runtime call needed).
575    ConfigurationCheck,
576    /// No invocation (general extensions just need to load).
577    NoOp,
578}
579
580impl ShapeInvocation {
581    /// Build a default invocation for a shape + registration snapshot.
582    ///
583    /// Uses the first registered tool/command/event to construct a minimal
584    /// invocation.  Returns `NoOp` if the shape doesn't support invocation
585    /// or no matching registration was found.
586    #[must_use]
587    #[allow(clippy::too_many_lines)]
588    pub fn default_for_shape(shape: ExtensionShape, snapshot: &RegistrationSnapshot) -> Self {
589        match shape {
590            ExtensionShape::Tool => snapshot.tools.first().map_or(Self::NoOp, |tool| {
591                let name = tool
592                    .get("name")
593                    .and_then(Value::as_str)
594                    .unwrap_or("unknown")
595                    .to_string();
596                Self::ToolCall {
597                    tool_name: name,
598                    arguments: Value::Object(serde_json::Map::new()),
599                }
600            }),
601            ExtensionShape::Command => snapshot.slash_commands.first().map_or(Self::NoOp, |cmd| {
602                let name = cmd
603                    .get("name")
604                    .and_then(Value::as_str)
605                    .unwrap_or("unknown")
606                    .to_string();
607                Self::CommandExec {
608                    command_name: name,
609                    args: String::new(),
610                }
611            }),
612            ExtensionShape::EventHook => {
613                snapshot
614                    .event_hooks
615                    .first()
616                    .map_or(Self::NoOp, |event| Self::EventDispatch {
617                        event_name: event.clone(),
618                        payload: Value::Object(serde_json::Map::new()),
619                    })
620            }
621            ExtensionShape::Provider => Self::ProviderCheck,
622            ExtensionShape::UiComponent => Self::UiComponentCheck,
623            ExtensionShape::Configuration => Self::ConfigurationCheck,
624            ExtensionShape::Multi => Self::multi_invocation(snapshot),
625            ExtensionShape::General => Self::NoOp,
626        }
627    }
628
629    /// Build invocation for Multi-type extensions by trying tool → command → event.
630    fn multi_invocation(snapshot: &RegistrationSnapshot) -> Self {
631        fn name_from_value(v: &Value) -> String {
632            v.get("name")
633                .and_then(Value::as_str)
634                .unwrap_or("unknown")
635                .to_string()
636        }
637
638        snapshot.tools.first().map_or_else(
639            || {
640                snapshot.slash_commands.first().map_or_else(
641                    || {
642                        snapshot.event_hooks.first().map_or(Self::NoOp, |event| {
643                            Self::EventDispatch {
644                                event_name: event.clone(),
645                                payload: Value::Object(serde_json::Map::new()),
646                            }
647                        })
648                    },
649                    |cmd| Self::CommandExec {
650                        command_name: name_from_value(cmd),
651                        args: String::new(),
652                    },
653                )
654            },
655            |tool| Self::ToolCall {
656                tool_name: name_from_value(tool),
657                arguments: Value::Object(serde_json::Map::new()),
658            },
659        )
660    }
661}
662
663// ────────────────────────────────────────────────────────────────────────────
664// Shape test result
665// ────────────────────────────────────────────────────────────────────────────
666
667/// Aggregate result for a single extension run through the shape harness.
668#[derive(Debug, Clone, Serialize, Deserialize)]
669pub struct ShapeTestResult {
670    pub extension_id: String,
671    pub extension_path: PathBuf,
672    pub shape: ExtensionShape,
673    pub detected_shape: ExtensionShape,
674    pub passed: bool,
675    pub events: Vec<ShapeEvent>,
676    pub failures: Vec<ShapeFailure>,
677    pub total_duration_ms: u64,
678}
679
680impl ShapeTestResult {
681    /// Render a compact summary line (for test output).
682    #[must_use]
683    pub fn summary_line(&self) -> String {
684        let status = if self.passed { "PASS" } else { "FAIL" };
685        let shape_match = if self.shape == self.detected_shape {
686            String::new()
687        } else {
688            format!(" (detected: {})", self.detected_shape)
689        };
690        format!(
691            "[{status}] {id} ({shape}{shape_match}) — {dur}ms, {n_fail} failures",
692            id = self.extension_id,
693            shape = self.shape,
694            dur = self.total_duration_ms,
695            n_fail = self.failures.len(),
696        )
697    }
698}
699
700// ────────────────────────────────────────────────────────────────────────────
701// Shape harness configuration
702// ────────────────────────────────────────────────────────────────────────────
703
704/// Configuration for running the shape harness.
705#[derive(Debug, Clone)]
706pub struct ShapeHarnessConfig {
707    /// Maximum time for extension load (ms).
708    pub load_timeout_ms: u64,
709    /// Maximum time for invocation (ms).
710    pub invoke_timeout_ms: u64,
711    /// Maximum time for shutdown (ms).
712    pub shutdown_timeout_ms: u64,
713    /// Deterministic time base (ms since epoch).
714    pub deterministic_time_ms: u64,
715    /// Deterministic CWD.
716    pub deterministic_cwd: PathBuf,
717    /// Deterministic HOME.
718    pub deterministic_home: PathBuf,
719    /// Custom invocation (overrides auto-detected default).
720    pub custom_invocation: Option<ShapeInvocation>,
721}
722
723impl Default for ShapeHarnessConfig {
724    fn default() -> Self {
725        Self {
726            load_timeout_ms: 20_000,
727            invoke_timeout_ms: 20_000,
728            shutdown_timeout_ms: 5_000,
729            deterministic_time_ms: 1_700_000_000_000,
730            deterministic_cwd: PathBuf::from("/tmp/ext-conformance-shapes"),
731            deterministic_home: PathBuf::from("/tmp/ext-conformance-shapes-home"),
732            custom_invocation: None,
733        }
734    }
735}
736
737// ────────────────────────────────────────────────────────────────────────────
738// Shape-to-fixture mapping
739// ────────────────────────────────────────────────────────────────────────────
740
741/// Known base fixtures per shape (under `tests/ext_conformance/artifacts/base_fixtures/`).
742#[must_use]
743pub const fn base_fixture_name(shape: ExtensionShape) -> Option<&'static str> {
744    match shape {
745        ExtensionShape::Tool => Some("minimal_tool"),
746        ExtensionShape::Command => Some("minimal_command"),
747        ExtensionShape::Provider => Some("minimal_provider"),
748        ExtensionShape::EventHook => Some("minimal_event"),
749        ExtensionShape::UiComponent => Some("minimal_ui_component"),
750        ExtensionShape::Configuration => Some("minimal_configuration"),
751        ExtensionShape::Multi => Some("minimal_multi"),
752        ExtensionShape::General => Some("minimal_resources"),
753    }
754}
755
756/// Build the path to a base fixture's entry point.
757#[must_use]
758pub fn base_fixture_path(repo_root: &Path, shape: ExtensionShape) -> Option<PathBuf> {
759    base_fixture_name(shape).map(|name| {
760        repo_root
761            .join("tests/ext_conformance/artifacts/base_fixtures")
762            .join(name)
763            .join("index.ts")
764    })
765}
766
767// ────────────────────────────────────────────────────────────────────────────
768// Batch runner types
769// ────────────────────────────────────────────────────────────────────────────
770
771/// Input specification for running the shape harness on one extension.
772#[derive(Debug, Clone, Serialize, Deserialize)]
773pub struct ShapeHarnessInput {
774    pub extension_id: String,
775    pub extension_path: PathBuf,
776    pub shape: ExtensionShape,
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub custom_invocation: Option<ShapeInvocation>,
779}
780
781/// Summary of a batch run.
782#[derive(Debug, Clone, Serialize, Deserialize)]
783pub struct ShapeBatchSummary {
784    pub total: usize,
785    pub passed: usize,
786    pub failed: usize,
787    pub skipped: usize,
788    pub pass_rate: f64,
789    pub by_shape: BTreeMap<String, ShapeShapeSummary>,
790    pub by_failure_class: BTreeMap<String, usize>,
791}
792
793/// Per-shape aggregate in batch summary.
794#[derive(Debug, Clone, Default, Serialize, Deserialize)]
795pub struct ShapeShapeSummary {
796    pub total: usize,
797    pub passed: usize,
798    pub failed: usize,
799}
800
801impl ShapeBatchSummary {
802    /// Build from a list of test results.
803    #[must_use]
804    pub fn from_results(results: &[ShapeTestResult]) -> Self {
805        let total = results.len();
806        let passed = results.iter().filter(|r| r.passed).count();
807        let failed = total - passed;
808
809        let mut by_shape: BTreeMap<String, ShapeShapeSummary> = BTreeMap::new();
810        let mut by_failure_class: BTreeMap<String, usize> = BTreeMap::new();
811
812        for result in results {
813            let shape_key = result.shape.to_string();
814            let entry = by_shape.entry(shape_key).or_default();
815            entry.total += 1;
816            if result.passed {
817                entry.passed += 1;
818            } else {
819                entry.failed += 1;
820            }
821            for failure in &result.failures {
822                *by_failure_class
823                    .entry(failure.class.to_string())
824                    .or_insert(0) += 1;
825            }
826        }
827
828        let pass_rate = if total == 0 {
829            0.0
830        } else {
831            #[allow(clippy::cast_precision_loss)]
832            {
833                passed as f64 / total as f64
834            }
835        };
836
837        Self {
838            total,
839            passed,
840            failed,
841            skipped: 0,
842            pass_rate,
843            by_shape,
844            by_failure_class,
845        }
846    }
847
848    /// Render a compact Markdown summary.
849    #[must_use]
850    pub fn render_markdown(&self) -> String {
851        use std::fmt::Write as _;
852        let mut out = String::new();
853        out.push_str("# Shape Conformance Summary\n\n");
854        let _ = write!(
855            out,
856            "Pass Rate: {:.1}% ({}/{})\n\n",
857            self.pass_rate * 100.0,
858            self.passed,
859            self.total
860        );
861
862        out.push_str("## By Shape\n\n");
863        out.push_str("| Shape | Total | Pass | Fail |\n");
864        out.push_str("|---|---:|---:|---:|\n");
865        for (shape, summary) in &self.by_shape {
866            let _ = writeln!(
867                out,
868                "| {shape} | {} | {} | {} |",
869                summary.total, summary.passed, summary.failed
870            );
871        }
872
873        if !self.by_failure_class.is_empty() {
874            out.push_str("\n## By Failure Class\n\n");
875            out.push_str("| Class | Count |\n");
876            out.push_str("|---|---:|\n");
877            for (class, count) in &self.by_failure_class {
878                let _ = writeln!(out, "| {class} | {count} |");
879            }
880        }
881
882        out
883    }
884}
885
886// ────────────────────────────────────────────────────────────────────────────
887// N/A remediation bucket classification
888// ────────────────────────────────────────────────────────────────────────────
889
890/// Remediation bucket for N/A (not-applicable) extension conformance states.
891///
892/// Each N/A extension is classified into exactly one bucket that describes
893/// why it cannot currently be tested and what action would unblock it.
894/// The buckets are ordered by actionability (most actionable first).
895#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
896#[serde(rename_all = "snake_case")]
897pub enum RemediationBucket {
898    /// Mock/VCR infrastructure doesn't cover the extension's hostcall or
899    /// HTTP interaction pattern. Fix: enhance `MockSpecInterceptor`,
900    /// `ConformanceSession`, or VCR stubs.
901    HarnessGap,
902    /// No test fixture (manifest, expected-output snapshot, or scenario spec)
903    /// exists for this extension. Fix: author the missing fixture file(s).
904    MissingFixture,
905    /// Extension requires a `QuickJS` virtual-module stub or host API that is
906    /// not yet implemented. Fix: add the stub in `extensions_js.rs` or
907    /// implement the missing hostcall.
908    MissingRuntimeApi,
909    /// Extension's required capability is denied by the conformance-harness
910    /// policy (e.g., `Exec` or `Http` blocked in sandbox). Fix: add a
911    /// policy override for the specific capability or extend the harness
912    /// sandbox allowlist.
913    PolicyBlocked,
914    /// Extension is intentionally excluded from conformance testing
915    /// (test fixtures, deprecated extensions, multi-file bundles that are
916    /// out-of-scope). No action required.
917    IntentionallyUnsupported,
918}
919
920impl RemediationBucket {
921    /// All buckets in canonical order.
922    #[must_use]
923    pub const fn all() -> &'static [Self] {
924        &[
925            Self::HarnessGap,
926            Self::MissingFixture,
927            Self::MissingRuntimeApi,
928            Self::PolicyBlocked,
929            Self::IntentionallyUnsupported,
930        ]
931    }
932
933    /// Human-readable description of what this bucket means.
934    #[must_use]
935    pub const fn description(&self) -> &'static str {
936        match self {
937            Self::HarnessGap => "Mock/VCR infrastructure gap prevents testing this extension",
938            Self::MissingFixture => "No test fixture exists for this extension",
939            Self::MissingRuntimeApi => {
940                "Extension requires a QuickJS shim or host API not yet implemented"
941            }
942            Self::PolicyBlocked => "Extension capability blocked by conformance-harness policy",
943            Self::IntentionallyUnsupported => "Intentionally excluded from conformance testing",
944        }
945    }
946
947    /// Short remediation hint.
948    #[must_use]
949    pub const fn remediation_hint(&self) -> &'static str {
950        match self {
951            Self::HarnessGap => "Enhance MockSpecInterceptor, ConformanceSession, or VCR stubs",
952            Self::MissingFixture => "Author manifest and expected-output fixture files",
953            Self::MissingRuntimeApi => {
954                "Add virtual module stub in extensions_js.rs or implement hostcall"
955            }
956            Self::PolicyBlocked => "Add policy override or extend harness sandbox allowlist",
957            Self::IntentionallyUnsupported => "No action required",
958        }
959    }
960}
961
962impl fmt::Display for RemediationBucket {
963    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
964        match self {
965            Self::HarnessGap => write!(f, "harness_gap"),
966            Self::MissingFixture => write!(f, "missing_fixture"),
967            Self::MissingRuntimeApi => write!(f, "missing_runtime_api"),
968            Self::PolicyBlocked => write!(f, "policy_blocked"),
969            Self::IntentionallyUnsupported => write!(f, "intentionally_unsupported"),
970        }
971    }
972}
973
974/// A single N/A classification entry: one extension mapped to a bucket.
975#[derive(Debug, Clone, Serialize, Deserialize)]
976pub struct NaClassification {
977    /// Extension identifier (e.g., `"npm/pi-wakatime"`).
978    pub extension_id: String,
979    /// Which remediation bucket this falls into.
980    pub bucket: RemediationBucket,
981    /// The underlying cause code from the failure taxonomy (e.g.,
982    /// `"missing_npm_package"`, `"manifest_mismatch"`).
983    #[serde(default, skip_serializing_if = "Option::is_none")]
984    pub cause_code: Option<String>,
985    /// Free-text detail about why this is N/A.
986    #[serde(default, skip_serializing_if = "Option::is_none")]
987    pub detail: Option<String>,
988}
989
990impl NaClassification {
991    #[must_use]
992    pub fn new(extension_id: impl Into<String>, bucket: RemediationBucket) -> Self {
993        Self {
994            extension_id: extension_id.into(),
995            bucket,
996            cause_code: None,
997            detail: None,
998        }
999    }
1000
1001    #[must_use]
1002    pub fn with_cause(mut self, cause: impl Into<String>) -> Self {
1003        self.cause_code = Some(cause.into());
1004        self
1005    }
1006
1007    #[must_use]
1008    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
1009        self.detail = Some(detail.into());
1010        self
1011    }
1012}
1013
1014/// Aggregate summary of N/A classifications.
1015#[derive(Debug, Clone, Serialize, Deserialize)]
1016pub struct NaClassificationSummary {
1017    /// Total number of N/A extensions classified.
1018    pub total: usize,
1019    /// Count per remediation bucket.
1020    pub by_bucket: BTreeMap<String, usize>,
1021    /// All individual classifications.
1022    pub classifications: Vec<NaClassification>,
1023}
1024
1025impl NaClassificationSummary {
1026    /// Build from a list of classifications.
1027    #[must_use]
1028    pub fn from_classifications(classifications: Vec<NaClassification>) -> Self {
1029        let total = classifications.len();
1030        let mut by_bucket: BTreeMap<String, usize> = BTreeMap::new();
1031        for c in &classifications {
1032            *by_bucket.entry(c.bucket.to_string()).or_insert(0) += 1;
1033        }
1034        Self {
1035            total,
1036            by_bucket,
1037            classifications,
1038        }
1039    }
1040
1041    /// Render a compact Markdown summary table.
1042    #[must_use]
1043    pub fn render_markdown(&self) -> String {
1044        use std::fmt::Write as _;
1045        let mut out = String::new();
1046        out.push_str("# N/A Classification Summary\n\n");
1047        let _ = writeln!(out, "Total N/A extensions: {}\n", self.total);
1048        out.push_str("| Bucket | Count | Description |\n");
1049        out.push_str("|---|---:|---|\n");
1050        for bucket in RemediationBucket::all() {
1051            let count = self
1052                .by_bucket
1053                .get(&bucket.to_string())
1054                .copied()
1055                .unwrap_or(0);
1056            let _ = writeln!(out, "| {bucket} | {count} | {} |", bucket.description());
1057        }
1058        out
1059    }
1060}
1061
1062/// Classify a failure cause code into a remediation bucket.
1063///
1064/// Maps the cause codes from `build_inventory.py` and the baseline JSON
1065/// to the five canonical remediation buckets.
1066#[must_use]
1067pub fn classify_cause_to_bucket(cause_code: &str) -> RemediationBucket {
1068    match cause_code {
1069        // Infrastructure / harness limitations
1070        "mock_gap" | "vcr_stub_gap" | "assertion_gap" => RemediationBucket::HarnessGap,
1071
1072        // Extension loads but expected registrations don't match
1073        "manifest_mismatch" => RemediationBucket::MissingFixture,
1074
1075        // Multi-file extensions and test fixtures are intentionally unsupported
1076        "multi_file_dependency" | "test_fixture" => RemediationBucket::IntentionallyUnsupported,
1077
1078        // Missing npm package, runtime error, or anything else → missing runtime API
1079        _ => RemediationBucket::MissingRuntimeApi,
1080    }
1081}
1082
1083/// Classify a `FailureClass` into a remediation bucket.
1084///
1085/// Maps the structured enum to the five canonical buckets.
1086#[must_use]
1087pub const fn classify_failure_to_bucket(failure: &FailureClass) -> RemediationBucket {
1088    match failure {
1089        FailureClass::LoadError | FailureClass::RuntimeShimGap => {
1090            RemediationBucket::MissingRuntimeApi
1091        }
1092        FailureClass::MissingRegistration | FailureClass::MalformedRegistration => {
1093            RemediationBucket::MissingFixture
1094        }
1095        FailureClass::InvocationError
1096        | FailureClass::OutputMismatch
1097        | FailureClass::Timeout
1098        | FailureClass::ShutdownError => RemediationBucket::HarnessGap,
1099        FailureClass::IncompatibleShape => RemediationBucket::IntentionallyUnsupported,
1100    }
1101}
1102
1103// ────────────────────────────────────────────────────────────────────────────
1104// Unit tests
1105// ────────────────────────────────────────────────────────────────────────────
1106
1107#[cfg(test)]
1108mod tests {
1109    use super::*;
1110
1111    #[test]
1112    fn shape_from_category_roundtrip() {
1113        let categories = [
1114            (ExtensionCategory::Tool, ExtensionShape::Tool),
1115            (ExtensionCategory::Command, ExtensionShape::Command),
1116            (ExtensionCategory::Provider, ExtensionShape::Provider),
1117            (ExtensionCategory::EventHook, ExtensionShape::EventHook),
1118            (ExtensionCategory::UiComponent, ExtensionShape::UiComponent),
1119            (
1120                ExtensionCategory::Configuration,
1121                ExtensionShape::Configuration,
1122            ),
1123            (ExtensionCategory::Multi, ExtensionShape::Multi),
1124            (ExtensionCategory::General, ExtensionShape::General),
1125        ];
1126        for (cat, expected_shape) in categories {
1127            assert_eq!(ExtensionShape::from_category(&cat), expected_shape);
1128        }
1129    }
1130
1131    #[test]
1132    fn shape_all_is_complete() {
1133        assert_eq!(ExtensionShape::all().len(), 8);
1134    }
1135
1136    #[test]
1137    fn verify_tool_missing_registration() {
1138        let snapshot = RegistrationSnapshot::default();
1139        let failures = verify_registrations(ExtensionShape::Tool, &snapshot);
1140        assert_eq!(failures.len(), 1);
1141        assert_eq!(failures[0].class, FailureClass::MissingRegistration);
1142        assert!(failures[0].message.contains("registerTool"));
1143    }
1144
1145    #[test]
1146    fn verify_tool_with_registration_passes() {
1147        let snapshot = RegistrationSnapshot {
1148            tools: vec![serde_json::json!({"name": "greet", "description": "Greets"})],
1149            ..Default::default()
1150        };
1151        let failures = verify_registrations(ExtensionShape::Tool, &snapshot);
1152        assert!(failures.is_empty());
1153    }
1154
1155    #[test]
1156    fn verify_tool_malformed_name() {
1157        let snapshot = RegistrationSnapshot {
1158            tools: vec![serde_json::json!({"description": "no name"})],
1159            ..Default::default()
1160        };
1161        let failures = verify_registrations(ExtensionShape::Tool, &snapshot);
1162        assert_eq!(failures.len(), 1);
1163        assert_eq!(failures[0].class, FailureClass::MalformedRegistration);
1164    }
1165
1166    #[test]
1167    fn verify_command_missing() {
1168        let snapshot = RegistrationSnapshot::default();
1169        let failures = verify_registrations(ExtensionShape::Command, &snapshot);
1170        assert_eq!(failures.len(), 1);
1171        assert_eq!(failures[0].class, FailureClass::MissingRegistration);
1172    }
1173
1174    #[test]
1175    fn verify_command_present() {
1176        let snapshot = RegistrationSnapshot {
1177            slash_commands: vec![serde_json::json!({"name": "ping"})],
1178            ..Default::default()
1179        };
1180        let failures = verify_registrations(ExtensionShape::Command, &snapshot);
1181        assert!(failures.is_empty());
1182    }
1183
1184    #[test]
1185    fn verify_provider_missing() {
1186        let snapshot = RegistrationSnapshot::default();
1187        let failures = verify_registrations(ExtensionShape::Provider, &snapshot);
1188        assert_eq!(failures.len(), 1);
1189    }
1190
1191    #[test]
1192    fn verify_provider_present() {
1193        let snapshot = RegistrationSnapshot {
1194            providers: vec![serde_json::json!({"name": "mock"})],
1195            ..Default::default()
1196        };
1197        let failures = verify_registrations(ExtensionShape::Provider, &snapshot);
1198        assert!(failures.is_empty());
1199    }
1200
1201    #[test]
1202    fn verify_event_hook_missing() {
1203        let snapshot = RegistrationSnapshot::default();
1204        let failures = verify_registrations(ExtensionShape::EventHook, &snapshot);
1205        assert_eq!(failures.len(), 1);
1206    }
1207
1208    #[test]
1209    fn verify_event_hook_present() {
1210        let snapshot = RegistrationSnapshot {
1211            event_hooks: vec!["agent_start".to_string()],
1212            ..Default::default()
1213        };
1214        let failures = verify_registrations(ExtensionShape::EventHook, &snapshot);
1215        assert!(failures.is_empty());
1216    }
1217
1218    #[test]
1219    fn verify_ui_component_missing() {
1220        let snapshot = RegistrationSnapshot::default();
1221        let failures = verify_registrations(ExtensionShape::UiComponent, &snapshot);
1222        assert_eq!(failures.len(), 1);
1223    }
1224
1225    #[test]
1226    fn verify_configuration_missing() {
1227        let snapshot = RegistrationSnapshot::default();
1228        let failures = verify_registrations(ExtensionShape::Configuration, &snapshot);
1229        assert_eq!(failures.len(), 1);
1230    }
1231
1232    #[test]
1233    fn verify_configuration_with_flag() {
1234        let snapshot = RegistrationSnapshot {
1235            flags: vec![serde_json::json!({"name": "verbose"})],
1236            ..Default::default()
1237        };
1238        let failures = verify_registrations(ExtensionShape::Configuration, &snapshot);
1239        assert!(failures.is_empty());
1240    }
1241
1242    #[test]
1243    fn verify_configuration_with_shortcut() {
1244        let snapshot = RegistrationSnapshot {
1245            shortcuts: vec![serde_json::json!({"key_id": "ctrl+t"})],
1246            ..Default::default()
1247        };
1248        let failures = verify_registrations(ExtensionShape::Configuration, &snapshot);
1249        assert!(failures.is_empty());
1250    }
1251
1252    #[test]
1253    fn verify_multi_insufficient_types() {
1254        let snapshot = RegistrationSnapshot {
1255            tools: vec![serde_json::json!({"name": "t"})],
1256            ..Default::default()
1257        };
1258        let failures = verify_registrations(ExtensionShape::Multi, &snapshot);
1259        assert_eq!(failures.len(), 1);
1260        assert!(failures[0].message.contains("2+ distinct types"));
1261    }
1262
1263    #[test]
1264    fn verify_multi_sufficient_types() {
1265        let snapshot = RegistrationSnapshot {
1266            tools: vec![serde_json::json!({"name": "t"})],
1267            event_hooks: vec!["agent_start".to_string()],
1268            ..Default::default()
1269        };
1270        let failures = verify_registrations(ExtensionShape::Multi, &snapshot);
1271        assert!(failures.is_empty());
1272    }
1273
1274    #[test]
1275    fn verify_general_always_passes() {
1276        let snapshot = RegistrationSnapshot::default();
1277        let failures = verify_registrations(ExtensionShape::General, &snapshot);
1278        assert!(failures.is_empty());
1279    }
1280
1281    #[test]
1282    fn detected_shape_classification() {
1283        let empty = RegistrationSnapshot::default();
1284        assert_eq!(empty.detected_shape(), ExtensionShape::General);
1285
1286        let tool = RegistrationSnapshot {
1287            tools: vec![serde_json::json!({"name": "t"})],
1288            ..Default::default()
1289        };
1290        assert_eq!(tool.detected_shape(), ExtensionShape::Tool);
1291
1292        let multi = RegistrationSnapshot {
1293            tools: vec![serde_json::json!({"name": "t"})],
1294            slash_commands: vec![serde_json::json!({"name": "c"})],
1295            ..Default::default()
1296        };
1297        assert_eq!(multi.detected_shape(), ExtensionShape::Multi);
1298    }
1299
1300    #[test]
1301    fn default_invocation_tool() {
1302        let snapshot = RegistrationSnapshot {
1303            tools: vec![serde_json::json!({"name": "greet"})],
1304            ..Default::default()
1305        };
1306        let inv = ShapeInvocation::default_for_shape(ExtensionShape::Tool, &snapshot);
1307        matches!(inv, ShapeInvocation::ToolCall { tool_name, .. } if tool_name == "greet");
1308    }
1309
1310    #[test]
1311    fn default_invocation_command() {
1312        let snapshot = RegistrationSnapshot {
1313            slash_commands: vec![serde_json::json!({"name": "ping"})],
1314            ..Default::default()
1315        };
1316        let inv = ShapeInvocation::default_for_shape(ExtensionShape::Command, &snapshot);
1317        matches!(inv, ShapeInvocation::CommandExec { command_name, .. } if command_name == "ping");
1318    }
1319
1320    #[test]
1321    fn default_invocation_event() {
1322        let snapshot = RegistrationSnapshot {
1323            event_hooks: vec!["agent_start".to_string()],
1324            ..Default::default()
1325        };
1326        let inv = ShapeInvocation::default_for_shape(ExtensionShape::EventHook, &snapshot);
1327        matches!(inv, ShapeInvocation::EventDispatch { event_name, .. } if event_name == "agent_start");
1328    }
1329
1330    #[test]
1331    fn default_invocation_provider() {
1332        let snapshot = RegistrationSnapshot {
1333            providers: vec![serde_json::json!({"name": "mock"})],
1334            ..Default::default()
1335        };
1336        let inv = ShapeInvocation::default_for_shape(ExtensionShape::Provider, &snapshot);
1337        matches!(inv, ShapeInvocation::ProviderCheck);
1338    }
1339
1340    #[test]
1341    fn default_invocation_general() {
1342        let snapshot = RegistrationSnapshot::default();
1343        let inv = ShapeInvocation::default_for_shape(ExtensionShape::General, &snapshot);
1344        matches!(inv, ShapeInvocation::NoOp);
1345    }
1346
1347    #[test]
1348    fn shape_failure_display() {
1349        let f = ShapeFailure::new(FailureClass::LoadError, "file not found")
1350            .with_path("extensions/foo.ts")
1351            .with_hint("Check the file path");
1352        let s = f.to_string();
1353        assert!(s.contains("load_error"));
1354        assert!(s.contains("file not found"));
1355        assert!(s.contains("extensions/foo.ts"));
1356        assert!(s.contains("Check the file path"));
1357    }
1358
1359    #[test]
1360    fn shape_event_jsonl_serialization() {
1361        let mut event = ShapeEvent::new(
1362            "corr-1",
1363            "hello",
1364            ExtensionShape::Tool,
1365            LifecyclePhase::Load,
1366        );
1367        event.duration_ms = 42;
1368        let jsonl = event.to_jsonl();
1369        let parsed: Value = serde_json::from_str(&jsonl).expect("valid JSON");
1370        assert_eq!(parsed["extension_id"], "hello");
1371        assert_eq!(parsed["shape"], "tool");
1372        assert_eq!(parsed["phase"], "load");
1373        assert_eq!(parsed["duration_ms"], 42);
1374    }
1375
1376    #[test]
1377    fn batch_summary_from_results() {
1378        let results = vec![
1379            ShapeTestResult {
1380                extension_id: "a".to_string(),
1381                extension_path: PathBuf::from("/a"),
1382                shape: ExtensionShape::Tool,
1383                detected_shape: ExtensionShape::Tool,
1384                passed: true,
1385                events: vec![],
1386                failures: vec![],
1387                total_duration_ms: 10,
1388            },
1389            ShapeTestResult {
1390                extension_id: "b".to_string(),
1391                extension_path: PathBuf::from("/b"),
1392                shape: ExtensionShape::Command,
1393                detected_shape: ExtensionShape::Command,
1394                passed: false,
1395                events: vec![],
1396                failures: vec![ShapeFailure::new(
1397                    FailureClass::MissingRegistration,
1398                    "no commands",
1399                )],
1400                total_duration_ms: 20,
1401            },
1402        ];
1403        let summary = ShapeBatchSummary::from_results(&results);
1404        assert_eq!(summary.total, 2);
1405        assert_eq!(summary.passed, 1);
1406        assert_eq!(summary.failed, 1);
1407        assert!((summary.pass_rate - 0.5).abs() < f64::EPSILON);
1408        assert_eq!(summary.by_shape["tool"].passed, 1);
1409        assert_eq!(summary.by_shape["command"].failed, 1);
1410        assert_eq!(summary.by_failure_class["missing_registration"], 1);
1411    }
1412
1413    #[test]
1414    fn batch_summary_markdown() {
1415        let results = vec![ShapeTestResult {
1416            extension_id: "a".to_string(),
1417            extension_path: PathBuf::from("/a"),
1418            shape: ExtensionShape::Tool,
1419            detected_shape: ExtensionShape::Tool,
1420            passed: true,
1421            events: vec![],
1422            failures: vec![],
1423            total_duration_ms: 10,
1424        }];
1425        let summary = ShapeBatchSummary::from_results(&results);
1426        let md = summary.render_markdown();
1427        assert!(md.contains("100.0%"));
1428        assert!(md.contains("| tool |"));
1429    }
1430
1431    #[test]
1432    fn registration_snapshot_field_count() {
1433        let snapshot = RegistrationSnapshot {
1434            tools: vec![
1435                serde_json::json!({"name": "a"}),
1436                serde_json::json!({"name": "b"}),
1437            ],
1438            flags: vec![serde_json::json!({"name": "f"})],
1439            ..Default::default()
1440        };
1441        assert_eq!(snapshot.field_count("tools"), 2);
1442        assert_eq!(snapshot.field_count("flags"), 1);
1443        assert_eq!(snapshot.field_count("slash_commands"), 0);
1444        assert_eq!(snapshot.field_count("unknown"), 0);
1445    }
1446
1447    #[test]
1448    fn registration_snapshot_total() {
1449        let snapshot = RegistrationSnapshot {
1450            tools: vec![serde_json::json!({"name": "t"})],
1451            event_hooks: vec!["e".to_string()],
1452            ..Default::default()
1453        };
1454        assert_eq!(snapshot.total_registrations(), 2);
1455    }
1456
1457    #[test]
1458    fn base_fixture_paths() {
1459        let root = Path::new("/repo");
1460        assert!(base_fixture_path(root, ExtensionShape::Tool).is_some());
1461        assert!(base_fixture_path(root, ExtensionShape::Command).is_some());
1462        assert!(base_fixture_path(root, ExtensionShape::Provider).is_some());
1463        assert!(base_fixture_path(root, ExtensionShape::EventHook).is_some());
1464        assert!(base_fixture_path(root, ExtensionShape::General).is_some());
1465        assert!(base_fixture_path(root, ExtensionShape::UiComponent).is_some());
1466        assert!(base_fixture_path(root, ExtensionShape::Configuration).is_some());
1467        assert!(base_fixture_path(root, ExtensionShape::Multi).is_some());
1468    }
1469
1470    #[test]
1471    fn shape_display() {
1472        assert_eq!(ExtensionShape::Tool.to_string(), "tool");
1473        assert_eq!(ExtensionShape::EventHook.to_string(), "event_hook");
1474        assert_eq!(ExtensionShape::UiComponent.to_string(), "ui_component");
1475    }
1476
1477    #[test]
1478    fn failure_class_display() {
1479        assert_eq!(FailureClass::LoadError.to_string(), "load_error");
1480        assert_eq!(
1481            FailureClass::MissingRegistration.to_string(),
1482            "missing_registration"
1483        );
1484        assert_eq!(FailureClass::RuntimeShimGap.to_string(), "runtime_shim_gap");
1485    }
1486
1487    #[test]
1488    fn shape_serde_roundtrip() {
1489        for shape in ExtensionShape::all() {
1490            let json = serde_json::to_string(shape).unwrap();
1491            let back: ExtensionShape = serde_json::from_str(&json).unwrap();
1492            assert_eq!(*shape, back);
1493        }
1494    }
1495
1496    #[test]
1497    fn failure_class_serde_roundtrip() {
1498        let classes = [
1499            FailureClass::LoadError,
1500            FailureClass::MissingRegistration,
1501            FailureClass::MalformedRegistration,
1502            FailureClass::InvocationError,
1503            FailureClass::OutputMismatch,
1504            FailureClass::Timeout,
1505            FailureClass::IncompatibleShape,
1506            FailureClass::ShutdownError,
1507            FailureClass::RuntimeShimGap,
1508        ];
1509        for class in classes {
1510            let json = serde_json::to_string(&class).unwrap();
1511            let back: FailureClass = serde_json::from_str(&json).unwrap();
1512            assert_eq!(class, back);
1513        }
1514    }
1515
1516    #[test]
1517    fn shape_result_summary_line() {
1518        let result = ShapeTestResult {
1519            extension_id: "hello".to_string(),
1520            extension_path: PathBuf::from("/ext/hello"),
1521            shape: ExtensionShape::Tool,
1522            detected_shape: ExtensionShape::Tool,
1523            passed: true,
1524            events: vec![],
1525            failures: vec![],
1526            total_duration_ms: 42,
1527        };
1528        let line = result.summary_line();
1529        assert!(line.contains("[PASS]"));
1530        assert!(line.contains("hello"));
1531        assert!(line.contains("tool"));
1532        assert!(line.contains("42ms"));
1533    }
1534
1535    #[test]
1536    fn shape_result_summary_line_mismatch() {
1537        let result = ShapeTestResult {
1538            extension_id: "x".to_string(),
1539            extension_path: PathBuf::from("/x"),
1540            shape: ExtensionShape::Tool,
1541            detected_shape: ExtensionShape::Multi,
1542            passed: false,
1543            events: vec![],
1544            failures: vec![ShapeFailure::new(FailureClass::OutputMismatch, "wrong")],
1545            total_duration_ms: 100,
1546        };
1547        let line = result.summary_line();
1548        assert!(line.contains("[FAIL]"));
1549        assert!(line.contains("(detected: multi)"));
1550        assert!(line.contains("1 failures"));
1551    }
1552
1553    #[test]
1554    fn typical_capabilities_nonempty() {
1555        for shape in ExtensionShape::all() {
1556            // All shapes have at least one typical capability
1557            let caps = shape.typical_capabilities();
1558            assert!(
1559                !caps.is_empty(),
1560                "Shape {shape} should have typical capabilities",
1561            );
1562        }
1563    }
1564
1565    #[test]
1566    fn supports_invocation_matches_expected() {
1567        assert!(ExtensionShape::Tool.supports_invocation());
1568        assert!(ExtensionShape::Command.supports_invocation());
1569        assert!(ExtensionShape::EventHook.supports_invocation());
1570        assert!(ExtensionShape::Multi.supports_invocation());
1571        assert!(!ExtensionShape::Provider.supports_invocation());
1572        assert!(!ExtensionShape::General.supports_invocation());
1573    }
1574
1575    // ── N/A remediation bucket tests ─────────────────────────────────────
1576
1577    #[test]
1578    fn remediation_bucket_all_is_complete() {
1579        assert_eq!(RemediationBucket::all().len(), 5);
1580    }
1581
1582    #[test]
1583    fn remediation_bucket_display() {
1584        assert_eq!(RemediationBucket::HarnessGap.to_string(), "harness_gap");
1585        assert_eq!(
1586            RemediationBucket::MissingFixture.to_string(),
1587            "missing_fixture"
1588        );
1589        assert_eq!(
1590            RemediationBucket::MissingRuntimeApi.to_string(),
1591            "missing_runtime_api"
1592        );
1593        assert_eq!(
1594            RemediationBucket::PolicyBlocked.to_string(),
1595            "policy_blocked"
1596        );
1597        assert_eq!(
1598            RemediationBucket::IntentionallyUnsupported.to_string(),
1599            "intentionally_unsupported"
1600        );
1601    }
1602
1603    #[test]
1604    fn remediation_bucket_serde_roundtrip() {
1605        for bucket in RemediationBucket::all() {
1606            let json = serde_json::to_string(bucket).unwrap();
1607            let back: RemediationBucket = serde_json::from_str(&json).unwrap();
1608            assert_eq!(*bucket, back);
1609        }
1610    }
1611
1612    #[test]
1613    fn remediation_bucket_descriptions_nonempty() {
1614        for bucket in RemediationBucket::all() {
1615            assert!(
1616                !bucket.description().is_empty(),
1617                "Bucket {bucket} should have a description"
1618            );
1619            assert!(
1620                !bucket.remediation_hint().is_empty(),
1621                "Bucket {bucket} should have a remediation hint"
1622            );
1623        }
1624    }
1625
1626    #[test]
1627    fn classify_cause_mock_gap_to_harness() {
1628        assert_eq!(
1629            classify_cause_to_bucket("mock_gap"),
1630            RemediationBucket::HarnessGap
1631        );
1632    }
1633
1634    #[test]
1635    fn classify_cause_vcr_stub_gap_to_harness() {
1636        assert_eq!(
1637            classify_cause_to_bucket("vcr_stub_gap"),
1638            RemediationBucket::HarnessGap
1639        );
1640    }
1641
1642    #[test]
1643    fn classify_cause_assertion_gap_to_harness() {
1644        assert_eq!(
1645            classify_cause_to_bucket("assertion_gap"),
1646            RemediationBucket::HarnessGap
1647        );
1648    }
1649
1650    #[test]
1651    fn classify_cause_manifest_mismatch_to_missing_fixture() {
1652        assert_eq!(
1653            classify_cause_to_bucket("manifest_mismatch"),
1654            RemediationBucket::MissingFixture
1655        );
1656    }
1657
1658    #[test]
1659    fn classify_cause_missing_npm_to_runtime_api() {
1660        assert_eq!(
1661            classify_cause_to_bucket("missing_npm_package"),
1662            RemediationBucket::MissingRuntimeApi
1663        );
1664    }
1665
1666    #[test]
1667    fn classify_cause_runtime_error_to_runtime_api() {
1668        assert_eq!(
1669            classify_cause_to_bucket("runtime_error"),
1670            RemediationBucket::MissingRuntimeApi
1671        );
1672    }
1673
1674    #[test]
1675    fn classify_cause_multi_file_to_unsupported() {
1676        assert_eq!(
1677            classify_cause_to_bucket("multi_file_dependency"),
1678            RemediationBucket::IntentionallyUnsupported
1679        );
1680    }
1681
1682    #[test]
1683    fn classify_cause_test_fixture_to_unsupported() {
1684        assert_eq!(
1685            classify_cause_to_bucket("test_fixture"),
1686            RemediationBucket::IntentionallyUnsupported
1687        );
1688    }
1689
1690    #[test]
1691    fn classify_cause_unknown_defaults_to_runtime_api() {
1692        assert_eq!(
1693            classify_cause_to_bucket("some_unknown_cause"),
1694            RemediationBucket::MissingRuntimeApi
1695        );
1696    }
1697
1698    #[test]
1699    fn classify_failure_load_error_to_runtime_api() {
1700        assert_eq!(
1701            classify_failure_to_bucket(&FailureClass::LoadError),
1702            RemediationBucket::MissingRuntimeApi
1703        );
1704    }
1705
1706    #[test]
1707    fn classify_failure_runtime_shim_gap_to_runtime_api() {
1708        assert_eq!(
1709            classify_failure_to_bucket(&FailureClass::RuntimeShimGap),
1710            RemediationBucket::MissingRuntimeApi
1711        );
1712    }
1713
1714    #[test]
1715    fn classify_failure_missing_registration_to_fixture() {
1716        assert_eq!(
1717            classify_failure_to_bucket(&FailureClass::MissingRegistration),
1718            RemediationBucket::MissingFixture
1719        );
1720    }
1721
1722    #[test]
1723    fn classify_failure_invocation_error_to_harness() {
1724        assert_eq!(
1725            classify_failure_to_bucket(&FailureClass::InvocationError),
1726            RemediationBucket::HarnessGap
1727        );
1728    }
1729
1730    #[test]
1731    fn classify_failure_timeout_to_harness() {
1732        assert_eq!(
1733            classify_failure_to_bucket(&FailureClass::Timeout),
1734            RemediationBucket::HarnessGap
1735        );
1736    }
1737
1738    #[test]
1739    fn classify_failure_incompatible_shape_to_unsupported() {
1740        assert_eq!(
1741            classify_failure_to_bucket(&FailureClass::IncompatibleShape),
1742            RemediationBucket::IntentionallyUnsupported
1743        );
1744    }
1745
1746    #[test]
1747    fn na_classification_builder() {
1748        let c = NaClassification::new("npm/pi-wakatime", RemediationBucket::MissingRuntimeApi)
1749            .with_cause("missing_npm_package")
1750            .with_detail("Requires openai npm package");
1751        assert_eq!(c.extension_id, "npm/pi-wakatime");
1752        assert_eq!(c.bucket, RemediationBucket::MissingRuntimeApi);
1753        assert_eq!(c.cause_code.as_deref(), Some("missing_npm_package"));
1754        assert_eq!(c.detail.as_deref(), Some("Requires openai npm package"));
1755    }
1756
1757    #[test]
1758    fn na_classification_serde_roundtrip() {
1759        let c =
1760            NaClassification::new("test/ext", RemediationBucket::HarnessGap).with_cause("mock_gap");
1761        let json = serde_json::to_string(&c).unwrap();
1762        let back: NaClassification = serde_json::from_str(&json).unwrap();
1763        assert_eq!(back.extension_id, "test/ext");
1764        assert_eq!(back.bucket, RemediationBucket::HarnessGap);
1765        assert_eq!(back.cause_code.as_deref(), Some("mock_gap"));
1766        assert!(back.detail.is_none());
1767    }
1768
1769    #[test]
1770    fn na_classification_summary_from_empty() {
1771        let summary = NaClassificationSummary::from_classifications(vec![]);
1772        assert_eq!(summary.total, 0);
1773        assert!(summary.by_bucket.is_empty());
1774    }
1775
1776    #[test]
1777    fn na_classification_summary_counts_buckets() {
1778        let classifications = vec![
1779            NaClassification::new("a", RemediationBucket::HarnessGap),
1780            NaClassification::new("b", RemediationBucket::HarnessGap),
1781            NaClassification::new("c", RemediationBucket::MissingFixture),
1782            NaClassification::new("d", RemediationBucket::MissingRuntimeApi),
1783            NaClassification::new("e", RemediationBucket::IntentionallyUnsupported),
1784        ];
1785        let summary = NaClassificationSummary::from_classifications(classifications);
1786        assert_eq!(summary.total, 5);
1787        assert_eq!(summary.by_bucket["harness_gap"], 2);
1788        assert_eq!(summary.by_bucket["missing_fixture"], 1);
1789        assert_eq!(summary.by_bucket["missing_runtime_api"], 1);
1790        assert_eq!(summary.by_bucket["intentionally_unsupported"], 1);
1791        assert!(!summary.by_bucket.contains_key("policy_blocked"));
1792    }
1793
1794    #[test]
1795    fn na_classification_summary_markdown() {
1796        let classifications = vec![
1797            NaClassification::new("x", RemediationBucket::MissingRuntimeApi),
1798            NaClassification::new("y", RemediationBucket::MissingRuntimeApi),
1799        ];
1800        let summary = NaClassificationSummary::from_classifications(classifications);
1801        let md = summary.render_markdown();
1802        assert!(md.contains("N/A Classification Summary"));
1803        assert!(md.contains("Total N/A extensions: 2"));
1804        assert!(md.contains("missing_runtime_api"));
1805        assert!(md.contains("| 2 |"));
1806    }
1807
1808    #[test]
1809    fn classify_all_baseline_causes_covered() {
1810        // Verify all cause codes from conformance_baseline.json map to valid buckets
1811        let baseline_causes = [
1812            "manifest_mismatch",
1813            "missing_npm_package",
1814            "multi_file_dependency",
1815            "runtime_error",
1816            "test_fixture",
1817            "mock_gap",
1818            "vcr_stub_gap",
1819        ];
1820        for cause in baseline_causes {
1821            let bucket = classify_cause_to_bucket(cause);
1822            // Every cause should map to a known bucket (not panic)
1823            assert!(
1824                RemediationBucket::all().contains(&bucket),
1825                "Cause {cause} mapped to unknown bucket"
1826            );
1827        }
1828    }
1829
1830    #[test]
1831    fn classify_all_failure_classes_covered() {
1832        let all_classes = [
1833            FailureClass::LoadError,
1834            FailureClass::MissingRegistration,
1835            FailureClass::MalformedRegistration,
1836            FailureClass::InvocationError,
1837            FailureClass::OutputMismatch,
1838            FailureClass::Timeout,
1839            FailureClass::IncompatibleShape,
1840            FailureClass::ShutdownError,
1841            FailureClass::RuntimeShimGap,
1842        ];
1843        for class in &all_classes {
1844            let bucket = classify_failure_to_bucket(class);
1845            assert!(
1846                RemediationBucket::all().contains(&bucket),
1847                "FailureClass {class} mapped to unknown bucket"
1848            );
1849        }
1850    }
1851
1852    #[test]
1853    fn na_classification_optional_fields_skip_serialization() {
1854        let c = NaClassification::new("ext", RemediationBucket::HarnessGap);
1855        let json = serde_json::to_string(&c).unwrap();
1856        // cause_code and detail should be absent (skip_serializing_if)
1857        assert!(!json.contains("cause_code"));
1858        assert!(!json.contains("detail"));
1859    }
1860
1861    mod proptest_conformance_shapes {
1862        use super::*;
1863        use proptest::prelude::*;
1864
1865        fn arb_extension_shape() -> impl Strategy<Value = ExtensionShape> {
1866            (0..8usize).prop_map(|i| ExtensionShape::all()[i])
1867        }
1868
1869        fn arb_failure_class() -> impl Strategy<Value = FailureClass> {
1870            prop_oneof![
1871                Just(FailureClass::LoadError),
1872                Just(FailureClass::MissingRegistration),
1873                Just(FailureClass::MalformedRegistration),
1874                Just(FailureClass::InvocationError),
1875                Just(FailureClass::OutputMismatch),
1876                Just(FailureClass::Timeout),
1877                Just(FailureClass::IncompatibleShape),
1878                Just(FailureClass::ShutdownError),
1879                Just(FailureClass::RuntimeShimGap),
1880            ]
1881        }
1882
1883        fn arb_remediation_bucket() -> impl Strategy<Value = RemediationBucket> {
1884            (0..5usize).prop_map(|i| RemediationBucket::all()[i])
1885        }
1886
1887        proptest! {
1888            /// `ExtensionShape` serde roundtrip for all variants.
1889            #[test]
1890            fn extension_shape_serde_roundtrip(shape in arb_extension_shape()) {
1891                let json = serde_json::to_string(&shape).unwrap();
1892                let back: ExtensionShape = serde_json::from_str(&json).unwrap();
1893                assert_eq!(shape, back);
1894            }
1895
1896            /// `FailureClass` serde roundtrip for all variants.
1897            #[test]
1898            fn failure_class_serde_roundtrip(fc in arb_failure_class()) {
1899                let json = serde_json::to_string(&fc).unwrap();
1900                let back: FailureClass = serde_json::from_str(&json).unwrap();
1901                assert_eq!(fc, back);
1902            }
1903
1904            /// `RemediationBucket` serde roundtrip.
1905            #[test]
1906            fn remediation_bucket_serde_roundtrip(bucket in arb_remediation_bucket()) {
1907                let json = serde_json::to_string(&bucket).unwrap();
1908                let back: RemediationBucket = serde_json::from_str(&json).unwrap();
1909                assert_eq!(bucket, back);
1910            }
1911
1912            /// `ExtensionShape::all()` covers exactly 8 variants.
1913            #[test]
1914            fn all_shapes_have_expected_fields(idx in 0..8usize) {
1915                let shape = ExtensionShape::all()[idx];
1916                let fields = shape.expected_registration_fields();
1917                // Every shape has at least an empty list or some fields
1918                assert!(fields.len() <= 6);
1919                for &f in fields {
1920                    assert!(!f.is_empty());
1921                }
1922            }
1923
1924            /// `supports_invocation` is consistent across all shapes.
1925            #[test]
1926            fn supports_invocation_is_deterministic(shape in arb_extension_shape()) {
1927                let a = shape.supports_invocation();
1928                let b = shape.supports_invocation();
1929                assert_eq!(a, b);
1930            }
1931
1932            /// `typical_capabilities` never panics and returns a non-empty vec.
1933            #[test]
1934            fn typical_capabilities_nonempty(shape in arb_extension_shape()) {
1935                let caps = shape.typical_capabilities();
1936                assert!(!caps.is_empty());
1937            }
1938
1939            /// `classify_cause_to_bucket` never panics on arbitrary strings.
1940            #[test]
1941            fn classify_cause_never_panics(cause in ".*") {
1942                let bucket = classify_cause_to_bucket(&cause);
1943                assert!(RemediationBucket::all().contains(&bucket));
1944            }
1945
1946            /// Known cause codes map to expected buckets.
1947            #[test]
1948            fn known_cause_codes_classify_correctly(
1949                idx in 0..5usize,
1950            ) {
1951                let (code, expected) = [
1952                    ("mock_gap", RemediationBucket::HarnessGap),
1953                    ("vcr_stub_gap", RemediationBucket::HarnessGap),
1954                    ("manifest_mismatch", RemediationBucket::MissingFixture),
1955                    ("multi_file_dependency", RemediationBucket::IntentionallyUnsupported),
1956                    ("test_fixture", RemediationBucket::IntentionallyUnsupported),
1957                ][idx];
1958                assert_eq!(classify_cause_to_bucket(code), expected);
1959            }
1960
1961            /// Unknown cause codes default to `MissingRuntimeApi`.
1962            #[test]
1963            fn unknown_cause_defaults_to_missing_api(cause in "[a-z_]{1,30}") {
1964                let known = ["mock_gap", "vcr_stub_gap", "assertion_gap",
1965                    "manifest_mismatch", "multi_file_dependency", "test_fixture"];
1966                if !known.contains(&cause.as_str()) {
1967                    assert_eq!(
1968                        classify_cause_to_bucket(&cause),
1969                        RemediationBucket::MissingRuntimeApi
1970                    );
1971                }
1972            }
1973
1974            /// `classify_failure_to_bucket` maps every failure class to a valid bucket.
1975            #[test]
1976            fn classify_failure_maps_to_valid_bucket(fc in arb_failure_class()) {
1977                let bucket = classify_failure_to_bucket(&fc);
1978                assert!(RemediationBucket::all().contains(&bucket));
1979            }
1980
1981            /// `RemediationBucket` description and hint are non-empty.
1982            #[test]
1983            fn bucket_description_and_hint_nonempty(bucket in arb_remediation_bucket()) {
1984                assert!(!bucket.description().is_empty());
1985                assert!(!bucket.remediation_hint().is_empty());
1986            }
1987
1988            /// `RegistrationSnapshot` field_count returns 0 for unknown fields.
1989            #[test]
1990            fn field_count_unknown_returns_zero(field in "[a-z]{10,20}") {
1991                let snapshot = RegistrationSnapshot::default();
1992                assert_eq!(snapshot.field_count(&field), 0);
1993            }
1994
1995            /// `RegistrationSnapshot` total is sum of all fields.
1996            #[test]
1997            fn total_registrations_is_sum(
1998                n_tools in 0..10usize,
1999                n_cmds in 0..10usize,
2000                n_providers in 0..5usize,
2001                n_hooks in 0..5usize,
2002                n_shortcuts in 0..5usize,
2003                n_flags in 0..5usize,
2004                n_models in 0..5usize,
2005                n_renderers in 0..5usize,
2006            ) {
2007                let null_val = || serde_json::Value::Null;
2008                let snapshot = RegistrationSnapshot {
2009                    tools: (0..n_tools).map(|_| null_val()).collect(),
2010                    slash_commands: (0..n_cmds).map(|_| null_val()).collect(),
2011                    providers: (0..n_providers).map(|_| null_val()).collect(),
2012                    event_hooks: (0..n_hooks).map(|_| "hook".to_string()).collect(),
2013                    shortcuts: (0..n_shortcuts).map(|_| null_val()).collect(),
2014                    flags: (0..n_flags).map(|_| null_val()).collect(),
2015                    models: (0..n_models).map(|_| null_val()).collect(),
2016                    message_renderers: (0..n_renderers).map(|_| null_val()).collect(),
2017                };
2018                assert_eq!(
2019                    snapshot.total_registrations(),
2020                    n_tools + n_cmds + n_providers + n_hooks + n_shortcuts
2021                        + n_flags + n_models + n_renderers
2022                );
2023            }
2024
2025            /// `NaClassification` builder produces valid structure.
2026            #[test]
2027            fn na_classification_builder(
2028                ext_id in "[a-z.]{1,15}",
2029                bucket in arb_remediation_bucket(),
2030                cause in "[a-z_]{1,20}",
2031                detail in "[a-zA-Z ]{0,50}",
2032            ) {
2033                let c = NaClassification::new(ext_id.clone(), bucket)
2034                    .with_cause(cause.clone())
2035                    .with_detail(detail.clone());
2036                assert_eq!(c.extension_id, ext_id);
2037                assert_eq!(c.bucket, bucket);
2038                assert_eq!(c.cause_code, Some(cause));
2039                assert_eq!(c.detail, Some(detail));
2040            }
2041
2042            /// `NaClassification` serde roundtrip.
2043            #[test]
2044            fn na_classification_serde_roundtrip(
2045                ext_id in "[a-z.]{1,15}",
2046                bucket in arb_remediation_bucket(),
2047            ) {
2048                let c = NaClassification::new(ext_id, bucket);
2049                let json = serde_json::to_string(&c).unwrap();
2050                let back: NaClassification = serde_json::from_str(&json).unwrap();
2051                assert_eq!(c.extension_id, back.extension_id);
2052                assert_eq!(c.bucket, back.bucket);
2053            }
2054        }
2055    }
2056}