ccid-cli 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: ccid-cli
3
+ Version: 0.1.1
4
+ Summary: CCID Compliance CLI - Pipeline-native compliance checking
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: typer>=0.9.0
7
+ Requires-Dist: httpx>=0.27.0
8
+ Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: websockets>=12.0
@@ -0,0 +1,4 @@
1
+ # Copyright (c) 2024-2026 Dr. Fred Mpala. All rights reserved.
2
+ # Proprietary and confidential. Unauthorized copying, distribution,
3
+ # or use of this file, via any medium, is strictly prohibited.
4
+
@@ -0,0 +1,169 @@
1
+ # Copyright (c) 2024-2026 Dr. Fred Mpala. All rights reserved.
2
+ # Proprietary and confidential. Unauthorized copying, distribution,
3
+ # or use of this file, via any medium, is strictly prohibited.
4
+
5
+ """
6
+ CCID API Client — httpx-based client for the CCID backend API.
7
+
8
+ Reads configuration from ~/.ccid/config.json and provides typed methods
9
+ for every endpoint the CLI needs.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+ from typing import Any, Optional
17
+
18
+ import httpx
19
+
20
+ CONFIG_PATH = Path.home() / ".ccid" / "config.json"
21
+
22
+
23
+ def load_config() -> dict[str, str]:
24
+ """Load saved configuration from ~/.ccid/config.json."""
25
+ if CONFIG_PATH.exists():
26
+ return json.loads(CONFIG_PATH.read_text())
27
+ return {}
28
+
29
+
30
+ def save_config(data: dict[str, str]) -> None:
31
+ """Persist configuration to ~/.ccid/config.json."""
32
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
33
+ CONFIG_PATH.write_text(json.dumps(data, indent=2))
34
+
35
+
36
+ class CCIDClient:
37
+ """Thin wrapper around the CCID REST API."""
38
+
39
+ def __init__(
40
+ self,
41
+ base_url: str | None = None,
42
+ api_key: str | None = None,
43
+ ) -> None:
44
+ cfg = load_config()
45
+ self.base_url = (base_url or cfg.get("api_url", "")).rstrip("/")
46
+ self.api_key = api_key or cfg.get("api_key", "")
47
+ if not self.base_url:
48
+ raise RuntimeError(
49
+ "No API URL configured. Run `ccid configure` first."
50
+ )
51
+ # `ccid analyze` blocks on a full synchronous code-analysis swarm —
52
+ # ~6 min even for a single resource (TRU-583: measured 376s), and the
53
+ # backend's Cloud Run request timeout is 3600s. A 120s client timeout
54
+ # made every sync analyze fail with "read operation timed out" before
55
+ # the swarm returned. 900s covers realistic analyses; use `--live`
56
+ # (WebSocket streaming) for very large repos that exceed it.
57
+ self._client = httpx.Client(
58
+ base_url=self.base_url,
59
+ headers=self._headers(),
60
+ timeout=900.0,
61
+ )
62
+
63
+ def _headers(self) -> dict[str, str]:
64
+ h: dict[str, str] = {"Content-Type": "application/json"}
65
+ if self.api_key:
66
+ # CCID API keys authenticate via X-API-Key ONLY. The
67
+ # Authorization: Bearer path is reserved for platform JWTs /
68
+ # Google ID tokens, so a ccid_ key sent there 401s (TRU-583).
69
+ h["X-API-Key"] = self.api_key
70
+ return h
71
+
72
+ # ------------------------------------------------------------------
73
+ # WebSocket helper
74
+ # ------------------------------------------------------------------
75
+
76
+ @property
77
+ def ws_url(self) -> str:
78
+ """Convert the HTTP(S) base_url to a WebSocket URL."""
79
+ url = self.base_url
80
+ if url.startswith("https://"):
81
+ return url.replace("https://", "wss://", 1) + "/ws"
82
+ if url.startswith("http://"):
83
+ return url.replace("http://", "ws://", 1) + "/ws"
84
+ return "ws://" + url + "/ws"
85
+
86
+ # ------------------------------------------------------------------
87
+ # Endpoints
88
+ # ------------------------------------------------------------------
89
+
90
+ def analyze(
91
+ self,
92
+ code: str,
93
+ frameworks: list[str] | None = None,
94
+ format: str = "json",
95
+ ) -> dict[str, Any]:
96
+ """POST /api/agents/run-code-analysis — trigger a code-analysis swarm."""
97
+ payload: dict[str, Any] = {
98
+ "terraform_code": code,
99
+ "trigger_source": "cli",
100
+ }
101
+ if frameworks:
102
+ payload["frameworks"] = frameworks
103
+ resp = self._client.post("/api/agents/run-code-analysis", json=payload)
104
+ resp.raise_for_status()
105
+ return resp.json()
106
+
107
+ def posture(
108
+ self,
109
+ scope: str = "system",
110
+ scope_id: str | None = None,
111
+ ) -> dict[str, Any]:
112
+ """GET /api/posture — compliance posture snapshot."""
113
+ params: dict[str, str] = {"scope": scope}
114
+ if scope_id:
115
+ params["scope_id"] = scope_id
116
+ resp = self._client.get("/api/posture", params=params)
117
+ resp.raise_for_status()
118
+ return resp.json()
119
+
120
+ def assignments(
121
+ self,
122
+ severity: str | None = None,
123
+ status: str = "open",
124
+ ) -> dict[str, Any]:
125
+ """GET /api/assignments — findings/assignments list."""
126
+ params: dict[str, str] = {"status": status}
127
+ if severity:
128
+ params["severity"] = severity
129
+ resp = self._client.get("/api/assignments", params=params)
130
+ resp.raise_for_status()
131
+ return resp.json()
132
+
133
+ def export(
134
+ self,
135
+ assessment_id: str,
136
+ format: str = "json",
137
+ ) -> str:
138
+ """GET /api/assessments/{id}/export/{format} — download assessment export."""
139
+ resp = self._client.get(
140
+ f"/api/assessments/{assessment_id}/export/{format}"
141
+ )
142
+ resp.raise_for_status()
143
+ return resp.text
144
+
145
+ def health(self) -> dict[str, Any]:
146
+ """GET /api/health — backend health check."""
147
+ resp = self._client.get("/api/health")
148
+ resp.raise_for_status()
149
+ return resp.json()
150
+
151
+ def swarm_results(self, assessment_id: str) -> dict[str, Any]:
152
+ """GET /api/assessments/{id}/swarm-results — swarm execution trace."""
153
+ resp = self._client.get(
154
+ f"/api/assessments/{assessment_id}/swarm-results"
155
+ )
156
+ resp.raise_for_status()
157
+ return resp.json()
158
+
159
+ def get(self, path: str, params: dict | None = None) -> dict[str, Any]:
160
+ """Generic GET request."""
161
+ resp = self._client.get(path, params=params)
162
+ resp.raise_for_status()
163
+ return resp.json()
164
+
165
+ def post(self, path: str, json_data: dict | None = None) -> dict[str, Any]:
166
+ """Generic POST request."""
167
+ resp = self._client.post(path, json=json_data or {})
168
+ resp.raise_for_status()
169
+ return resp.json()
@@ -0,0 +1,480 @@
1
+ # Copyright (c) 2024-2026 Dr. Fred Mpala. All rights reserved.
2
+ # Proprietary and confidential. Unauthorized copying, distribution,
3
+ # or use of this file, via any medium, is strictly prohibited.
4
+
5
+ """
6
+ CCID Formatters — Rich-based output rendering for CLI commands.
7
+
8
+ Every public function accepts the JSON dict returned by the API and
9
+ prints a Rich renderable to the console.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+
21
+ console = Console()
22
+
23
+ # -- Colour mappings -------------------------------------------------------
24
+
25
+ _POSTURE_COLOURS = {
26
+ "pass": "bold green",
27
+ "warn": "bold yellow",
28
+ "block": "bold red",
29
+ "unknown": "dim",
30
+ }
31
+
32
+ _SEVERITY_COLOURS = {
33
+ "critical": "bold red",
34
+ "high": "red",
35
+ "medium": "yellow",
36
+ "low": "cyan",
37
+ "info": "dim",
38
+ }
39
+
40
+ _STATUS_ICONS = {
41
+ "completed": "[green]OK[/green]",
42
+ "running": "[yellow]...[/yellow]",
43
+ "failed": "[red]FAIL[/red]",
44
+ "pending": "[dim]--[/dim]",
45
+ }
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Posture
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def format_posture(data: dict[str, Any]) -> None:
53
+ """Render compliance posture as a Rich table + summary panel."""
54
+ posture = data.get("posture", "unknown")
55
+ score = data.get("score", 0)
56
+ summary = data.get("summary", "")
57
+ coverage = data.get("obligation_coverage", {})
58
+ traceability = data.get("traceability", {})
59
+
60
+ # Header panel
61
+ posture_style = _POSTURE_COLOURS.get(posture, "dim")
62
+ header = Text()
63
+ header.append("Posture: ", style="bold")
64
+ header.append(posture.upper(), style=posture_style)
65
+ header.append(f" Score: {score:.0%}", style="bold")
66
+ header.append(f"\n{summary}")
67
+ header.append(
68
+ f"\nObligations: {coverage.get('assessed', 0)}/{coverage.get('total', 0)} assessed"
69
+ f" | Traceability links: {traceability.get('total_links', 0)}"
70
+ )
71
+ console.print(Panel(header, title="Compliance Posture", border_style=posture_style))
72
+
73
+ # Framework breakdown table
74
+ frameworks = data.get("frameworks", [])
75
+ if frameworks:
76
+ tbl = Table(title="Framework Breakdown", show_lines=True)
77
+ tbl.add_column("Framework", style="bold")
78
+ tbl.add_column("Compliant", justify="right")
79
+ tbl.add_column("Total", justify="right")
80
+ tbl.add_column("Critical", justify="right", style="red")
81
+ tbl.add_column("High", justify="right", style="yellow")
82
+ tbl.add_column("Last Assessed")
83
+ for fw in frameworks:
84
+ tbl.add_row(
85
+ fw.get("code", ""),
86
+ str(fw.get("obligations_compliant", 0)),
87
+ str(fw.get("obligations_total", 0)),
88
+ str(fw.get("critical_findings", 0)),
89
+ str(fw.get("high_findings", 0)),
90
+ fw.get("last_assessed", "--") or "--",
91
+ )
92
+ console.print(tbl)
93
+
94
+ # Top findings
95
+ top = data.get("top_findings", [])
96
+ if top:
97
+ tbl = Table(title="Top Findings", show_lines=True)
98
+ tbl.add_column("Severity", justify="center")
99
+ tbl.add_column("Title")
100
+ tbl.add_column("Resource")
101
+ tbl.add_column("Status")
102
+ for f in top:
103
+ sev = f.get("severity", "medium")
104
+ tbl.add_row(
105
+ Text(sev.upper(), style=_SEVERITY_COLOURS.get(sev, "")),
106
+ f.get("title", ""),
107
+ f.get("affected_resource", ""),
108
+ f.get("status", "open"),
109
+ )
110
+ console.print(tbl)
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Assignments
115
+ # ---------------------------------------------------------------------------
116
+
117
+ def format_assignments(data: dict[str, Any]) -> None:
118
+ """Render findings/assignments as a Rich table."""
119
+ items = data.get("items", [])
120
+ total = data.get("total", len(items))
121
+
122
+ tbl = Table(title=f"Assignments ({total} total)", show_lines=True)
123
+ tbl.add_column("Severity", justify="center", width=10)
124
+ tbl.add_column("Title", max_width=40)
125
+ tbl.add_column("Status", width=10)
126
+ tbl.add_column("Framework", width=14)
127
+ tbl.add_column("Resource", max_width=30)
128
+ tbl.add_column("Remediation", max_width=40)
129
+
130
+ for item in items:
131
+ sev = item.get("severity", "medium")
132
+ fw = ""
133
+ if item.get("obligation"):
134
+ fw = item["obligation"].get("framework", "")
135
+ tbl.add_row(
136
+ Text(sev.upper(), style=_SEVERITY_COLOURS.get(sev, "")),
137
+ item.get("title", ""),
138
+ item.get("status", "open"),
139
+ fw,
140
+ item.get("affected_resource", "") or "",
141
+ item.get("remediation", "") or "",
142
+ )
143
+
144
+ console.print(tbl)
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Analysis result
149
+ # ---------------------------------------------------------------------------
150
+
151
+ def format_analysis_result(data: dict[str, Any]) -> None:
152
+ """Render a code-analysis response as a panel with gate decision + findings."""
153
+ gate = data.get("gate_decision", "unknown")
154
+ confidence = data.get("confidence_score", 0) or 0
155
+ assessment_id = data.get("id", "")
156
+ findings = data.get("findings", [])
157
+
158
+ gate_style = _POSTURE_COLOURS.get(gate, "dim")
159
+
160
+ # Gate decision panel
161
+ header = Text()
162
+ header.append("Gate: ", style="bold")
163
+ header.append(gate.upper(), style=gate_style)
164
+ header.append(f" Confidence: {confidence:.0%}", style="bold")
165
+ header.append(f"\nAssessment: {assessment_id}")
166
+ header.append(f"\nFindings: {len(findings)}")
167
+ console.print(Panel(header, title="Code Analysis Result", border_style=gate_style))
168
+
169
+ if not findings:
170
+ console.print("[green]No findings - code looks compliant.[/green]")
171
+ return
172
+
173
+ tbl = Table(title="Findings", show_lines=True)
174
+ tbl.add_column("Severity", justify="center", width=10)
175
+ tbl.add_column("Title", max_width=40)
176
+ tbl.add_column("Resource", max_width=30)
177
+ tbl.add_column("Remediation", max_width=50)
178
+
179
+ for f in findings:
180
+ sev = f.get("severity", "medium")
181
+ tbl.add_row(
182
+ Text(sev.upper(), style=_SEVERITY_COLOURS.get(sev, "")),
183
+ f.get("title", ""),
184
+ f.get("affected_resource", "") or "",
185
+ f.get("remediation", "") or "",
186
+ )
187
+
188
+ console.print(tbl)
189
+
190
+ # Show remediation code blocks if present
191
+ for f in findings:
192
+ if f.get("remediation_code"):
193
+ console.print(
194
+ Panel(
195
+ f["remediation_code"],
196
+ title=f"Fix: {f.get('title', '')}",
197
+ border_style="green",
198
+ )
199
+ )
200
+
201
+ # Show pattern recommendations if present
202
+ recs = data.get("pattern_recommendations", [])
203
+ if recs:
204
+ format_pattern_recommendations(recs)
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Swarm replay
209
+ # ---------------------------------------------------------------------------
210
+
211
+ def format_swarm_replay(data: dict[str, Any]) -> None:
212
+ """Render swarm execution nodes as sequential reasoning panels with timing."""
213
+ execution = data.get("execution", {})
214
+ nodes = data.get("nodes", [])
215
+
216
+ # Execution summary header
217
+ swarm_type = execution.get("swarm_type", "unknown")
218
+ status = execution.get("status", "unknown")
219
+ total_tokens = execution.get("total_llm_tokens", 0)
220
+ total_duration = execution.get("total_duration_ms", 0)
221
+ total_nodes = execution.get("total_nodes", 0)
222
+ completed_nodes = execution.get("completed_nodes", 0)
223
+
224
+ header = Text()
225
+ header.append("Swarm: ", style="bold")
226
+ header.append(swarm_type, style="cyan bold")
227
+ header.append(f" Status: ", style="bold")
228
+ header.append(status.upper(), style=_POSTURE_COLOURS.get(status, "dim"))
229
+ header.append(f"\nNodes: {completed_nodes}/{total_nodes}")
230
+ header.append(f" Tokens: {total_tokens:,}")
231
+ if total_duration:
232
+ header.append(f" Duration: {total_duration / 1000:.1f}s")
233
+ console.print(
234
+ Panel(header, title="Swarm Execution Replay", border_style="cyan")
235
+ )
236
+
237
+ if not nodes:
238
+ console.print("[dim]No node results to replay.[/dim]")
239
+ return
240
+
241
+ for i, node in enumerate(nodes, 1):
242
+ name = node.get("name", f"node-{i}")
243
+ node_status = node.get("status", "unknown")
244
+ reasoning = node.get("reasoning", "")
245
+ tokens = node.get("llm_tokens_used", 0)
246
+ duration = node.get("duration_ms", 0)
247
+
248
+ # Status indicator
249
+ status_indicator = _STATUS_ICONS.get(node_status, f"[dim]{node_status}[/dim]")
250
+
251
+ # Build subtitle with timing
252
+ subtitle_parts = [f"{status_indicator}"]
253
+ if tokens:
254
+ subtitle_parts.append(f"{tokens:,} tokens")
255
+ if duration:
256
+ subtitle_parts.append(f"{duration / 1000:.1f}s")
257
+ subtitle = " | ".join(subtitle_parts)
258
+
259
+ # Reasoning content
260
+ content = Text()
261
+ if reasoning:
262
+ # Truncate very long reasoning but keep it readable
263
+ display_reasoning = reasoning if len(reasoning) <= 1000 else reasoning[:997] + "..."
264
+ content.append(display_reasoning)
265
+ else:
266
+ content.append("(no reasoning captured)", style="dim")
267
+
268
+ # Output data summary if present
269
+ output = node.get("output_data")
270
+ if output and isinstance(output, dict):
271
+ content.append("\n\nOutput keys: ", style="bold")
272
+ content.append(", ".join(output.keys()), style="dim")
273
+
274
+ border = "green" if node_status == "completed" else "red" if node_status == "failed" else "yellow"
275
+ console.print(
276
+ Panel(
277
+ content,
278
+ title=f"[{i}/{len(nodes)}] {name}",
279
+ subtitle=subtitle,
280
+ border_style=border,
281
+ )
282
+ )
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Pattern Recommendations
287
+ # ---------------------------------------------------------------------------
288
+
289
+ def format_pattern_recommendations(
290
+ patterns: list[dict[str, Any]],
291
+ framework: str | None = None,
292
+ control: str | None = None,
293
+ ) -> None:
294
+ """Render pattern recommendations as Rich panels with reasoning chains."""
295
+ if not patterns:
296
+ console.print("[dim]No matching golden patterns found.[/dim]")
297
+ return
298
+
299
+ title = "Golden Pattern Recommendations"
300
+ if framework and control:
301
+ title += f" for {framework}/{control}"
302
+
303
+ tbl = Table(title=title, show_lines=True)
304
+ tbl.add_column("Pattern", style="bold", max_width=30)
305
+ tbl.add_column("Type", width=12)
306
+ tbl.add_column("Satisfaction", justify="center", width=14)
307
+ tbl.add_column("Confidence", justify="right", width=12)
308
+ tbl.add_column("Controls", max_width=30)
309
+
310
+ for p in patterns:
311
+ # Handle both search results and recommendation results
312
+ name = p.get("pattern_name", p.get("name", ""))
313
+ ptype = p.get("pattern_type", "")
314
+ sat = p.get("satisfaction_level", "")
315
+ conf = p.get("confidence", 0)
316
+
317
+ controls = p.get("controls_satisfied", [])
318
+ ctrl_str = ", ".join(c.get("control_id", "") for c in controls[:4]) if controls else ""
319
+
320
+ conf_style = "green" if conf >= 0.8 else "yellow" if conf >= 0.5 else "red"
321
+ tbl.add_row(
322
+ name, ptype, sat,
323
+ Text(f"{conf:.0%}" if conf else "", style=conf_style),
324
+ ctrl_str,
325
+ )
326
+
327
+ console.print(tbl)
328
+
329
+ # Show reasoning chains for top recommendations
330
+ for p in patterns[:3]:
331
+ chain = p.get("reasoning_chain", {})
332
+ if not chain:
333
+ continue
334
+
335
+ steps = chain.get("steps", [])
336
+ if not steps:
337
+ continue
338
+
339
+ content = Text()
340
+ for step in steps[:5]:
341
+ content.append(f"\n{step.get('step', '?')}. [{step.get('phase', '')}] ", style="bold")
342
+ content.append(step.get("reasoning", "")[:200])
343
+ content.append(f"\n → {step.get('output', '')[:150]}", style="dim")
344
+
345
+ conclusion = chain.get("final_conclusion", "")
346
+ if conclusion:
347
+ content.append(f"\n\nConclusion: ", style="bold green")
348
+ content.append(conclusion[:200])
349
+
350
+ console.print(Panel(
351
+ content,
352
+ title=f"Reasoning: {p.get('pattern_name', p.get('name', ''))}",
353
+ border_style="cyan",
354
+ ))
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # Review Queue
359
+ # ---------------------------------------------------------------------------
360
+
361
+ _PRIORITY_COLOURS = {
362
+ "critical": "bold red",
363
+ "high": "red",
364
+ "medium": "yellow",
365
+ "low": "cyan",
366
+ }
367
+
368
+
369
+ def format_review_queue(items: list[dict[str, Any]]) -> None:
370
+ """Render pending review queue items as a Rich table."""
371
+ if not items:
372
+ console.print("[green]Review queue is empty — nothing pending.[/green]")
373
+ return
374
+
375
+ tbl = Table(title=f"Review Queue ({len(items)} pending)", show_lines=True)
376
+ tbl.add_column("Priority", justify="center", width=10)
377
+ tbl.add_column("Type", width=18)
378
+ tbl.add_column("Control", width=14)
379
+ tbl.add_column("Reason", max_width=25)
380
+ tbl.add_column("Status", width=12)
381
+ tbl.add_column("Assigned To", max_width=20)
382
+ tbl.add_column("Created", width=16)
383
+
384
+ for item in items:
385
+ p = item.get("priority", "medium")
386
+ tbl.add_row(
387
+ Text(p.upper(), style=_PRIORITY_COLOURS.get(p, "")),
388
+ item.get("target_type", ""),
389
+ item.get("control_id", "") or "",
390
+ item.get("queue_reason", ""),
391
+ item.get("status", "pending"),
392
+ item.get("assigned_to", "") or "--",
393
+ (item.get("created_at", "") or "")[:16],
394
+ )
395
+
396
+ console.print(tbl)
397
+
398
+
399
+ def format_review_queue_stats(data: dict[str, Any]) -> None:
400
+ """Render review queue statistics."""
401
+ total = data.get("total", 0)
402
+ by_status = data.get("by_status", {})
403
+ by_target = data.get("by_target_type", {})
404
+ by_priority = data.get("by_priority", {})
405
+ avg_time = data.get("avg_time_to_review_hours")
406
+
407
+ header = Text()
408
+ header.append(f"Total items: {total}\n", style="bold")
409
+ if avg_time is not None:
410
+ header.append(f"Avg time to review: {avg_time:.1f} hours\n")
411
+
412
+ header.append("\nBy Status: ", style="bold")
413
+ for s, c in by_status.items():
414
+ header.append(f" {s}: {c}")
415
+
416
+ header.append("\nBy Priority: ", style="bold")
417
+ for p, c in sorted(by_priority.items()):
418
+ header.append(f" {p}: {c}")
419
+
420
+ header.append("\nBy Type: ", style="bold")
421
+ for t, c in by_target.items():
422
+ header.append(f" {t}: {c}")
423
+
424
+ console.print(Panel(header, title="Review Queue Statistics"))
425
+
426
+
427
+ # ---------------------------------------------------------------------------
428
+ # Pattern Validation
429
+ # ---------------------------------------------------------------------------
430
+
431
+ _VALIDATION_COLOURS = {
432
+ "passed": "bold green",
433
+ "partial": "yellow",
434
+ "failed": "bold red",
435
+ "needs_policy": "magenta",
436
+ "pending": "dim",
437
+ "validating": "cyan",
438
+ }
439
+
440
+
441
+ def format_pattern_validation(data: dict[str, Any]) -> None:
442
+ """Render pattern validation results."""
443
+ pattern_name = data.get("pattern_name", "Unknown")
444
+ status = data.get("validation_status", "unknown")
445
+ summary = data.get("summary", {})
446
+
447
+ status_style = _VALIDATION_COLOURS.get(status, "dim")
448
+ header = Text()
449
+ header.append("Pattern: ", style="bold")
450
+ header.append(pattern_name, style="cyan")
451
+ header.append(f"\nValidation: ", style="bold")
452
+ header.append(status.upper(), style=status_style)
453
+ header.append(f"\nClaims: {summary.get('total_claims', 0)}")
454
+ header.append(f" Passed: {summary.get('passed', 0)}")
455
+ header.append(f" Failed: {summary.get('failed', 0)}")
456
+ if summary.get("needs_policy", 0):
457
+ header.append(f" Needs Policy: {summary.get('needs_policy', 0)}", style="magenta")
458
+
459
+ console.print(Panel(header, title="Pattern Validation Result", border_style=status_style))
460
+
461
+ # Detail per validation
462
+ validations = data.get("validations", [])
463
+ for v in validations:
464
+ v_status = v.get("overall_status", "unknown")
465
+ v_style = _VALIDATION_COLOURS.get(v_status, "dim")
466
+
467
+ content = Text()
468
+ content.append(f"Type: {v.get('claim_type', '')}\n")
469
+ content.append(f"Claim: {v.get('claim_description', '')}\n")
470
+ content.append(f"Status: ", style="bold")
471
+ content.append(v_status.upper(), style=v_style)
472
+ content.append(f" Confidence: {v.get('confidence', 0):.0%}\n")
473
+
474
+ steps = v.get("steps", [])
475
+ for s in steps:
476
+ step_status = s.get("status", "")
477
+ icon = {"passed": "[green]PASS[/green]", "failed": "[red]FAIL[/red]", "warning": "[yellow]WARN[/yellow]"}.get(step_status, step_status)
478
+ content.append(f"\n Step {s.get('step', '?')}: [{s.get('type', '')}] {icon}")
479
+
480
+ console.print(Panel(content, border_style=v_style))