1use 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#[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum FailureClass {
154 LoadError,
156 MissingRegistration,
158 MalformedRegistration,
160 InvocationError,
162 OutputMismatch,
164 Timeout,
166 IncompatibleShape,
168 ShutdownError,
170 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#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct ShapeFailure {
193 pub class: FailureClass,
194 pub message: String,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub path: Option<String>,
198 #[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#[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#[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#[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 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 #[must_use]
313 pub fn to_jsonl(&self) -> String {
314 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
315 }
316}
317
318#[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 #[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 #[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 #[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#[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 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 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 }
540 }
541
542 failures
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize)]
554#[serde(tag = "kind", rename_all = "snake_case")]
555pub enum ShapeInvocation {
556 ToolCall { tool_name: String, arguments: Value },
558 CommandExec {
560 command_name: String,
561 #[serde(default)]
562 args: String,
563 },
564 EventDispatch {
566 event_name: String,
567 #[serde(default)]
568 payload: Value,
569 },
570 ProviderCheck,
572 UiComponentCheck,
574 ConfigurationCheck,
576 NoOp,
578}
579
580impl ShapeInvocation {
581 #[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 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#[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 #[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#[derive(Debug, Clone)]
706pub struct ShapeHarnessConfig {
707 pub load_timeout_ms: u64,
709 pub invoke_timeout_ms: u64,
711 pub shutdown_timeout_ms: u64,
713 pub deterministic_time_ms: u64,
715 pub deterministic_cwd: PathBuf,
717 pub deterministic_home: PathBuf,
719 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#[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#[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#[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#[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#[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
896#[serde(rename_all = "snake_case")]
897pub enum RemediationBucket {
898 HarnessGap,
902 MissingFixture,
905 MissingRuntimeApi,
909 PolicyBlocked,
914 IntentionallyUnsupported,
918}
919
920impl RemediationBucket {
921 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
976pub struct NaClassification {
977 pub extension_id: String,
979 pub bucket: RemediationBucket,
981 #[serde(default, skip_serializing_if = "Option::is_none")]
984 pub cause_code: Option<String>,
985 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
1016pub struct NaClassificationSummary {
1017 pub total: usize,
1019 pub by_bucket: BTreeMap<String, usize>,
1021 pub classifications: Vec<NaClassification>,
1023}
1024
1025impl NaClassificationSummary {
1026 #[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 #[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#[must_use]
1067pub fn classify_cause_to_bucket(cause_code: &str) -> RemediationBucket {
1068 match cause_code {
1069 "mock_gap" | "vcr_stub_gap" | "assertion_gap" => RemediationBucket::HarnessGap,
1071
1072 "manifest_mismatch" => RemediationBucket::MissingFixture,
1074
1075 "multi_file_dependency" | "test_fixture" => RemediationBucket::IntentionallyUnsupported,
1077
1078 _ => RemediationBucket::MissingRuntimeApi,
1080 }
1081}
1082
1083#[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#[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 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 #[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 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 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 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 #[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 #[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 #[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 #[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 assert!(fields.len() <= 6);
1919 for &f in fields {
1920 assert!(!f.is_empty());
1921 }
1922 }
1923
1924 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}