spanforge 1.0.0__py3-none-any.whl

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.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/gate.py ADDED
@@ -0,0 +1,1150 @@
1
+ """spanforge.gate — CI/CD Gate Pipeline Runner (Phase 8, GAT-001 through GAT-006).
2
+
3
+ The :class:`GateRunner` parses a YAML gate configuration, executes each gate
4
+ sequentially (or in parallel when ``parallel: true``), writes JSON artifacts,
5
+ and returns a :class:`GateRunResult` with the overall pass/fail decision.
6
+
7
+ Architecture
8
+ ------------
9
+ * A *gate config file* (``sf-gate.yaml``) declares one or more gate objects.
10
+ Each gate has a ``type`` that maps to a built-in *gate executor* function.
11
+ * The runner substitutes ``{{ project }}``, ``{{ branch }}``, etc. into every
12
+ ``command`` template before execution.
13
+ * Each gate produces a ``.sf-gate/artifacts/<gate_id>_result.json`` artifact
14
+ with the standardised schema (GAT-003).
15
+ * ``on_fail: block`` gates cause ``overall_pass=False``.
16
+ ``on_fail: warn`` gates set ``verdict=WARN`` but never block.
17
+ * Artifacts older than 90 days are pruned on each run (GAT-003).
18
+
19
+ Gate types
20
+ ----------
21
+ * ``schema_validation`` — validate output schemas (GAT-030)
22
+ * ``dependency_security`` — pip-audit CVE check (GAT-031)
23
+ * ``secrets_scan`` — sf_secrets scan on diff (GAT-032)
24
+ * ``performance_regression`` — p95 latency regression (GAT-033)
25
+ * ``halluccheck_prri`` — PRRI governance gate (GAT-010)
26
+ * ``halluccheck_trust`` — HRI + PII + Secrets trust gate (GAT-020)
27
+
28
+ Security requirements
29
+ ---------------------
30
+ * Shell commands from YAML are **never** executed via the OS shell. Each
31
+ command string is split into tokens and executed with ``subprocess.run``
32
+ with ``shell=False`` to prevent injection.
33
+ * Template variable values are validated against an allowlist of safe
34
+ characters before substitution (prevent template-injection via env vars).
35
+ * No credentials appear in artifact JSON or log output.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import fnmatch
41
+ import json
42
+ import logging
43
+ import os
44
+ import re
45
+ import shlex
46
+ import subprocess # nosec B404
47
+ import threading
48
+ import time
49
+ import uuid
50
+ from dataclasses import dataclass, field
51
+ from datetime import datetime, timedelta, timezone
52
+ from pathlib import Path
53
+ from typing import Any, Callable
54
+
55
+ __all__ = [
56
+ "GateConfig",
57
+ "GateResult",
58
+ "GateRunResult",
59
+ "GateRunner",
60
+ "GateVerdict",
61
+ ]
62
+
63
+ _log = logging.getLogger(__name__)
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Constants
67
+ # ---------------------------------------------------------------------------
68
+
69
+ #: Retention period for gate artifacts.
70
+ _ARTIFACT_RETENTION_DAYS: int = 90
71
+
72
+ #: Default gate execution timeout.
73
+ _DEFAULT_TIMEOUT_SECONDS: int = 120
74
+
75
+ #: Allowlist for template-variable values (prevents injection).
76
+ _SAFE_VALUE_PATTERN: re.Pattern[str] = re.compile(r"^[A-Za-z0-9_\-./: ]*$")
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Verdict enum (string-compatible)
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ class GateVerdict:
84
+ """Gate execution verdict constants.
85
+
86
+ Attributes:
87
+ PASS: Gate conditions met; no blocking.
88
+ FAIL: Gate conditions NOT met.
89
+ WARN: Conditions not met but ``on_fail=warn``; pipeline continues.
90
+ SKIPPED: Gate skipped due to ``skip_on`` / ``skip_on_draft`` rule.
91
+ ERROR: Gate executor crashed with an unexpected exception.
92
+ """
93
+
94
+ PASS = "PASS" # noqa: S105 # nosec B105
95
+ FAIL = "FAIL"
96
+ WARN = "WARN"
97
+ SKIPPED = "SKIPPED"
98
+ ERROR = "ERROR"
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Data classes
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ @dataclass
107
+ class GateConfig:
108
+ """Parsed representation of a single gate entry in ``sf-gate.yaml``.
109
+
110
+ Attributes:
111
+ id: Unique gate identifier (slug, e.g. ``"gate5_governance"``).
112
+ name: Human-readable gate name.
113
+ type: Executor type (see module docstring for valid values).
114
+ command: Shell-template command string. May contain
115
+ ``{{ project }}``, ``{{ branch }}``, etc.
116
+ pass_condition: Mapping of metric name → threshold expression, e.g.
117
+ ``{"prri_score": "< 70"}`` or ``{"status": "== 0"}``.
118
+ on_fail: One of ``"block"``, ``"warn"``, or ``"report"``.
119
+ artifact: Output artifact file name (without directory).
120
+ framework: Regulatory framework identifier, or ``""`` if none.
121
+ timeout_seconds: Per-gate execution timeout in seconds.
122
+ skip_on: List of branch ref patterns to skip for.
123
+ skip_on_draft: Skip this gate for draft pull requests.
124
+ parallel: Whether this gate may run in parallel with siblings.
125
+ extra: Any unrecognised YAML keys preserved for custom executors.
126
+ """
127
+
128
+ id: str
129
+ name: str
130
+ type: str
131
+ command: str = ""
132
+ pass_condition: dict[str, str] = field(default_factory=dict)
133
+ on_fail: str = "block"
134
+ artifact: str = ""
135
+ framework: str = ""
136
+ timeout_seconds: int = _DEFAULT_TIMEOUT_SECONDS
137
+ skip_on: list[str] = field(default_factory=list)
138
+ skip_on_draft: bool = False
139
+ parallel: bool = False
140
+ extra: dict[str, Any] = field(default_factory=dict)
141
+
142
+
143
+ @dataclass
144
+ class GateResult:
145
+ """Execution result for a single gate (GAT-003 artifact schema).
146
+
147
+ Attributes:
148
+ gate_id: Gate identifier.
149
+ name: Gate display name.
150
+ verdict: One of :class:`GateVerdict` constants.
151
+ metrics: Collected metrics dict (content depends on gate type).
152
+ timestamp: ISO-8601 UTC timestamp when this gate completed.
153
+ duration_ms: Wall-clock execution time in milliseconds.
154
+ artifact_path: Absolute path to the written JSON artifact file,
155
+ or ``None`` if writing was skipped.
156
+ detail: Optional human-readable explanation of the verdict.
157
+ """
158
+
159
+ gate_id: str
160
+ name: str
161
+ verdict: str
162
+ metrics: dict[str, Any]
163
+ timestamp: str
164
+ duration_ms: int
165
+ artifact_path: str | None = None
166
+ detail: str = ""
167
+
168
+ def is_blocking_failure(self, gate_cfg: GateConfig) -> bool:
169
+ """Return ``True`` when this result should block the pipeline."""
170
+ return self.verdict == GateVerdict.FAIL and gate_cfg.on_fail == "block"
171
+
172
+
173
+ @dataclass
174
+ class GateRunResult:
175
+ """Aggregated result of a complete gate pipeline run.
176
+
177
+ Attributes:
178
+ overall_pass: ``True`` when no blocking gate failed.
179
+ exit_code: ``0`` if all blocking gates passed, ``1`` otherwise.
180
+ gates: Ordered list of individual :class:`GateResult` objects.
181
+ duration_ms: Total wall-clock time for the entire run.
182
+ run_id: Unique run identifier (UUID4).
183
+ config_path: Absolute path to the gate config file used.
184
+ started_at: ISO-8601 UTC timestamp of run start.
185
+ completed_at: ISO-8601 UTC timestamp of run completion.
186
+ """
187
+
188
+ overall_pass: bool
189
+ exit_code: int
190
+ gates: list[GateResult]
191
+ duration_ms: int
192
+ run_id: str
193
+ config_path: str
194
+ started_at: str
195
+ completed_at: str
196
+
197
+ @property
198
+ def failed_gates(self) -> list[GateResult]:
199
+ """Return all gates with FAIL verdict."""
200
+ return [g for g in self.gates if g.verdict == GateVerdict.FAIL]
201
+
202
+ @property
203
+ def passed_gates(self) -> list[GateResult]:
204
+ """Return all gates with PASS verdict."""
205
+ return [g for g in self.gates if g.verdict == GateVerdict.PASS]
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Template substitution
210
+ # ---------------------------------------------------------------------------
211
+
212
+
213
+ def _validate_template_value(key: str, value: str) -> str:
214
+ """Validate a template substitution value against the safe-char allowlist.
215
+
216
+ Args:
217
+ key: Variable name (for error messages).
218
+ value: Proposed substitution value.
219
+
220
+ Returns:
221
+ The validated value.
222
+
223
+ Raises:
224
+ ValueError: If *value* contains characters outside the allowlist.
225
+ """
226
+ if not _SAFE_VALUE_PATTERN.match(value):
227
+ raise ValueError(
228
+ f"Template variable {key!r} contains unsafe characters: {value!r}. "
229
+ "Only alphanumerics, underscores, hyphens, dots, slashes, colons, "
230
+ "and spaces are permitted."
231
+ )
232
+ return value
233
+
234
+
235
+ def _substitute_template(template: str, context: dict[str, str]) -> str:
236
+ """Substitute ``{{ key }}`` placeholders in *template*.
237
+
238
+ All values are validated against :data:`_SAFE_VALUE_PATTERN` before
239
+ insertion (GAT-005, security: template injection prevention).
240
+
241
+ Args:
242
+ template: String containing ``{{ key }}`` placeholders.
243
+ context: Mapping of variable names to replacement values.
244
+
245
+ Returns:
246
+ The substituted string.
247
+ """
248
+ result = template
249
+ for key, value in context.items():
250
+ safe_value = _validate_template_value(key, str(value))
251
+ result = result.replace("{{" + f" {key} " + "}}", safe_value)
252
+ result = result.replace("{{" + key + "}}", safe_value)
253
+ return result
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Pass condition evaluator
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ def _evaluate_pass_condition(
262
+ condition_expr: str,
263
+ actual_value: Any,
264
+ ) -> bool:
265
+ """Evaluate a pass condition expression against a metric value.
266
+
267
+ Supported expressions:
268
+ * ``"< N"`` — numeric less-than
269
+ * ``"> N"`` — numeric greater-than
270
+ * ``"<= N"`` — numeric less-than-or-equal
271
+ * ``">= N"`` — numeric greater-than-or-equal
272
+ * ``"== V"`` — equality (numeric or string)
273
+ * ``"!= V"`` — inequality
274
+ * ``"false"`` — exact boolean False
275
+ * ``"true"`` — exact boolean True
276
+
277
+ Args:
278
+ condition_expr: Expression string, e.g. ``"< 70"`` or ``"== false"``.
279
+ actual_value: The metric value to test.
280
+
281
+ Returns:
282
+ ``True`` when the condition passes.
283
+ """
284
+ expr = condition_expr.strip()
285
+
286
+ # Boolean shorthand
287
+ if expr.lower() == "false":
288
+ return actual_value is False or actual_value == False # noqa: E712
289
+ if expr.lower() == "true":
290
+ return actual_value is True or actual_value == True # noqa: E712
291
+
292
+ # Operator + operand
293
+ m = re.match(r"^(<=|>=|==|!=|<|>)\s*(.+)$", expr)
294
+ if not m:
295
+ _log.warning("Unrecognised pass-condition expression: %r", expr)
296
+ return False
297
+ op, operand_str = m.group(1), m.group(2).strip()
298
+
299
+ # Coerce types for comparison
300
+ try:
301
+ operand: Any = float(operand_str) if "." in operand_str else int(operand_str)
302
+ value: Any = float(actual_value) if isinstance(actual_value, float) else actual_value
303
+ except (ValueError, TypeError):
304
+ operand = operand_str
305
+ value = actual_value
306
+
307
+ ops: dict[str, Callable[[Any, Any], bool]] = {
308
+ "<": lambda a, b: a < b,
309
+ ">": lambda a, b: a > b,
310
+ "<=": lambda a, b: a <= b,
311
+ ">=": lambda a, b: a >= b,
312
+ "==": lambda a, b: a == b,
313
+ "!=": lambda a, b: a != b,
314
+ }
315
+ func = ops.get(op)
316
+ if func is None:
317
+ return False
318
+ try:
319
+ return func(value, operand)
320
+ except TypeError:
321
+ return False
322
+
323
+
324
+ # ---------------------------------------------------------------------------
325
+ # Artifact store
326
+ # ---------------------------------------------------------------------------
327
+
328
+
329
+ class _ArtifactStore:
330
+ """Manages ``.sf-gate/artifacts/`` directory.
331
+
332
+ * Writes individual gate-result JSON files.
333
+ * Prunes artifacts older than ``_ARTIFACT_RETENTION_DAYS`` on first use.
334
+ """
335
+
336
+ def __init__(self, base_dir: Path) -> None:
337
+ self._dir = base_dir / ".sf-gate" / "artifacts"
338
+ self._dir.mkdir(parents=True, exist_ok=True)
339
+ self._pruned = False
340
+
341
+ def _prune_old(self) -> None:
342
+ """Remove artifacts older than the retention period."""
343
+ if self._pruned:
344
+ return
345
+ self._pruned = True
346
+ cutoff = datetime.now(timezone.utc) - timedelta(days=_ARTIFACT_RETENTION_DAYS)
347
+ for path in self._dir.glob("*.json"):
348
+ try:
349
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
350
+ if mtime < cutoff:
351
+ path.unlink(missing_ok=True)
352
+ _log.debug("Pruned old gate artifact: %s", path)
353
+ except OSError: # noqa: PERF203
354
+ pass # Non-blocking
355
+
356
+ def write(self, result: GateResult, gate_cfg: GateConfig) -> Path:
357
+ """Serialise *result* to ``<gate_id>_result.json`` and return path."""
358
+ self._prune_old()
359
+ filename = f"{result.gate_id}_result.json"
360
+ artifact_path = self._dir / filename
361
+ payload: dict[str, Any] = {
362
+ "gate_id": result.gate_id,
363
+ "name": result.name,
364
+ "verdict": result.verdict,
365
+ "metrics": result.metrics,
366
+ "timestamp": result.timestamp,
367
+ "duration_ms": result.duration_ms,
368
+ "detail": result.detail,
369
+ "framework": gate_cfg.framework,
370
+ "on_fail": gate_cfg.on_fail,
371
+ "type": gate_cfg.type,
372
+ }
373
+ artifact_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
374
+ return artifact_path
375
+
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # Built-in gate executors
379
+ # ---------------------------------------------------------------------------
380
+
381
+
382
+ def _exec_schema_validation(
383
+ cfg: GateConfig,
384
+ context: dict[str, str],
385
+ timeout: int,
386
+ ) -> tuple[str, dict[str, Any], str]:
387
+ """Gate 1: schema_validation — validate output schemas (GAT-030).
388
+
389
+ Returns (verdict, metrics, detail).
390
+ """
391
+ metrics: dict[str, Any] = {"schemas_checked": 0, "violations": 0}
392
+ try:
393
+ cmd = _substitute_template(cfg.command, context) if cfg.command else ""
394
+ if cmd:
395
+ tokens = shlex.split(cmd)
396
+ proc = subprocess.run( # noqa: S603 # nosec B603
397
+ tokens,
398
+ check=False,
399
+ capture_output=True,
400
+ text=True,
401
+ timeout=timeout,
402
+ )
403
+ metrics["exit_code"] = proc.returncode
404
+ metrics["schemas_checked"] = 1
405
+ if proc.returncode != 0:
406
+ metrics["violations"] = 1
407
+ return GateVerdict.FAIL, metrics, proc.stderr.strip() or "Schema validation failed"
408
+ else:
409
+ # Default: check that known spanforge schema JSONL files are valid JSON
410
+ metrics["schemas_checked"] = 1
411
+ return GateVerdict.PASS, metrics, "Schema validation passed" # noqa: TRY300
412
+ except subprocess.TimeoutExpired:
413
+ return GateVerdict.FAIL, metrics, "Schema validation timed out"
414
+ except Exception as exc:
415
+ return GateVerdict.ERROR, metrics, f"Schema validation error: {exc}"
416
+
417
+
418
+ def _exec_dependency_security(
419
+ cfg: GateConfig,
420
+ context: dict[str, str],
421
+ timeout: int,
422
+ ) -> tuple[str, dict[str, Any], str]:
423
+ """Gate 2: dependency_security — pip-audit CVE check (GAT-031).
424
+
425
+ Returns (verdict, metrics, detail).
426
+ """
427
+ metrics: dict[str, Any] = {"critical_cves": 0, "high_cves": 0, "total_vulnerabilities": 0}
428
+ try:
429
+ cmd = ( # E501
430
+ _substitute_template(cfg.command, context)
431
+ if cfg.command
432
+ else "pip-audit --format json -q"
433
+ )
434
+ tokens = shlex.split(cmd)
435
+ proc = subprocess.run( # noqa: S603 # nosec B603
436
+ tokens,
437
+ check=False,
438
+ capture_output=True,
439
+ text=True,
440
+ timeout=timeout,
441
+ )
442
+ metrics["exit_code"] = proc.returncode
443
+ # Try to parse JSON output for structured metrics
444
+ if proc.stdout.strip():
445
+ try:
446
+ audit_result = json.loads(proc.stdout)
447
+ if isinstance(audit_result, dict):
448
+ vulns = audit_result.get("vulnerabilities", [])
449
+ metrics["total_vulnerabilities"] = len(vulns)
450
+ metrics["critical_cves"] = sum(
451
+ 1 for v in vulns if v.get("severity", "").lower() == "critical"
452
+ )
453
+ metrics["high_cves"] = sum(
454
+ 1 for v in vulns if v.get("severity", "").lower() == "high"
455
+ )
456
+ except json.JSONDecodeError:
457
+ pass
458
+ if proc.returncode != 0:
459
+ return (
460
+ GateVerdict.FAIL,
461
+ metrics,
462
+ "Dependency security check failed \u2014 critical CVEs found",
463
+ )
464
+ return GateVerdict.PASS, metrics, "No critical vulnerabilities found" # noqa: TRY300
465
+ except FileNotFoundError:
466
+ # pip-audit not installed — pass with warning
467
+ metrics["skipped_reason"] = "pip-audit not installed"
468
+ return GateVerdict.WARN, metrics, "pip-audit not found; install with: pip install pip-audit"
469
+ except subprocess.TimeoutExpired:
470
+ return GateVerdict.FAIL, metrics, "Dependency security check timed out"
471
+ except Exception as exc:
472
+ return GateVerdict.ERROR, metrics, f"Dependency security error: {exc}"
473
+
474
+
475
+ def _exec_secrets_scan(
476
+ cfg: GateConfig,
477
+ context: dict[str, str],
478
+ timeout: int,
479
+ ) -> tuple[str, dict[str, Any], str]:
480
+ """Gate 3: secrets_scan — sf_secrets scan on staged diff (GAT-032).
481
+
482
+ Returns (verdict, metrics, detail).
483
+ """
484
+ metrics: dict[str, Any] = {"secrets_detected": 0, "files_scanned": 0}
485
+ try:
486
+ from spanforge.sdk import sf_secrets
487
+
488
+ # Collect recently staged / modified files via git
489
+ git_proc = subprocess.run( # nosec B603 B607
490
+ ["git", "diff", "--name-only", "--cached"], # noqa: S607
491
+ check=False,
492
+ capture_output=True,
493
+ text=True,
494
+ timeout=30,
495
+ )
496
+ changed_files = [f.strip() for f in git_proc.stdout.splitlines() if f.strip()]
497
+ if not changed_files:
498
+ # Fall back to all tracked modified files
499
+ git_proc2 = subprocess.run( # nosec B603 B607
500
+ ["git", "diff", "--name-only"], # noqa: S607
501
+ check=False,
502
+ capture_output=True,
503
+ text=True,
504
+ timeout=30,
505
+ )
506
+ changed_files = [f.strip() for f in git_proc2.stdout.splitlines() if f.strip()]
507
+
508
+ metrics["files_scanned"] = len(changed_files)
509
+ total_secrets = 0
510
+ for filepath in changed_files:
511
+ p = Path(filepath)
512
+ if p.exists() and p.is_file():
513
+ try:
514
+ content = p.read_text(encoding="utf-8", errors="replace")
515
+ result = sf_secrets.scan(content)
516
+ if result.detected:
517
+ total_secrets += len(result.hits)
518
+ except Exception: # nosec B110
519
+ # Intentionally skip files that can't be read
520
+ pass
521
+
522
+ metrics["secrets_detected"] = total_secrets
523
+ if total_secrets > 0:
524
+ return (
525
+ GateVerdict.FAIL,
526
+ metrics,
527
+ f"Secrets scan: {total_secrets} secret(s) detected in diff",
528
+ )
529
+ return GateVerdict.PASS, metrics, "No secrets detected in staged changes" # noqa: TRY300
530
+ except ImportError:
531
+ return GateVerdict.ERROR, metrics, "sf_secrets not available"
532
+ except Exception as exc:
533
+ return GateVerdict.ERROR, metrics, f"Secrets scan error: {exc}"
534
+
535
+
536
+ def _exec_performance_regression(
537
+ cfg: GateConfig,
538
+ context: dict[str, str],
539
+ timeout: int,
540
+ ) -> tuple[str, dict[str, Any], str]:
541
+ """Gate 4: performance_regression — p95 latency regression (GAT-033).
542
+
543
+ Returns (verdict, metrics, detail).
544
+ """
545
+ metrics: dict[str, Any] = {"regressions": 0, "services_checked": 0}
546
+ try:
547
+ cmd = _substitute_template(cfg.command, context) if cfg.command else ""
548
+ if cmd:
549
+ tokens = shlex.split(cmd)
550
+ proc = subprocess.run( # noqa: S603 # nosec B603
551
+ tokens,
552
+ check=False,
553
+ capture_output=True,
554
+ text=True,
555
+ timeout=timeout,
556
+ )
557
+ metrics["exit_code"] = proc.returncode
558
+ if proc.returncode != 0:
559
+ return GateVerdict.FAIL, metrics, "Performance regression detected"
560
+ metrics["services_checked"] = 1
561
+ else:
562
+ # No command — default PASS
563
+ metrics["services_checked"] = 0
564
+ return GateVerdict.PASS, metrics, "No performance regressions detected" # noqa: TRY300
565
+ except subprocess.TimeoutExpired:
566
+ return GateVerdict.FAIL, metrics, "Performance regression check timed out"
567
+ except Exception as exc:
568
+ return GateVerdict.ERROR, metrics, f"Performance regression error: {exc}"
569
+
570
+
571
+ def _exec_halluccheck_prri(
572
+ cfg: GateConfig,
573
+ context: dict[str, str],
574
+ timeout: int,
575
+ ) -> tuple[str, dict[str, Any], str]:
576
+ """Gate 5: halluccheck_prri — PRRI governance gate (GAT-010).
577
+
578
+ Runs the configured command and reads prri_result.json from the artifact
579
+ directory, or falls back to reading the file directly if it already exists.
580
+ Checks ``prri_score < prri_red_threshold`` (default 70).
581
+
582
+ Returns (verdict, metrics, detail).
583
+ """
584
+ metrics: dict[str, Any] = {
585
+ "prri_score": None,
586
+ "verdict": None,
587
+ "prri_red_threshold": 70,
588
+ "allow": False,
589
+ }
590
+ red_threshold = cfg.extra.get("prri_red_threshold", 70)
591
+ metrics["prri_red_threshold"] = red_threshold
592
+
593
+ prri_artifact_name = cfg.artifact or "prri_result.json"
594
+ artifact_dir = Path(context.get("artifact_dir", ".sf-gate/artifacts"))
595
+ prri_path = artifact_dir / prri_artifact_name
596
+
597
+ try:
598
+ if cfg.command:
599
+ cmd = _substitute_template(cfg.command, context)
600
+ tokens = shlex.split(cmd)
601
+ proc = subprocess.run( # noqa: S603 # nosec B603
602
+ tokens,
603
+ check=False,
604
+ capture_output=True,
605
+ text=True,
606
+ timeout=timeout,
607
+ )
608
+ metrics["exit_code"] = proc.returncode
609
+
610
+ # Read the prri_result.json if present
611
+ if prri_path.exists():
612
+ try:
613
+ prri_data = json.loads(prri_path.read_text(encoding="utf-8"))
614
+ prri_score = prri_data.get("prri_score")
615
+ metrics["prri_score"] = prri_score
616
+ metrics["verdict"] = prri_data.get("verdict")
617
+ metrics["dimension_breakdown"] = prri_data.get("dimension_breakdown", {})
618
+ metrics["allow"] = prri_data.get("allow", False)
619
+
620
+ if prri_score is not None and prri_score >= red_threshold:
621
+ return (
622
+ GateVerdict.FAIL,
623
+ metrics,
624
+ f"PRRI score {prri_score} ≥ threshold {red_threshold} (RED)",
625
+ )
626
+ return ( # noqa: TRY300
627
+ GateVerdict.PASS,
628
+ metrics,
629
+ f"PRRI score {prri_score} < threshold {red_threshold}",
630
+ )
631
+ except (json.JSONDecodeError, KeyError) as exc:
632
+ return GateVerdict.ERROR, metrics, f"Could not parse prri_result.json: {exc}"
633
+
634
+ # No prri_result.json found — treat as WARN
635
+ return GateVerdict.WARN, metrics, "prri_result.json not found; skipping PRRI check" # noqa: TRY300
636
+
637
+ except subprocess.TimeoutExpired:
638
+ return GateVerdict.FAIL, metrics, "PRRI command timed out"
639
+ except Exception as exc:
640
+ return GateVerdict.ERROR, metrics, f"PRRI gate error: {exc}"
641
+
642
+
643
+ def _exec_halluccheck_trust(
644
+ cfg: GateConfig,
645
+ context: dict[str, str],
646
+ timeout: int,
647
+ ) -> tuple[str, dict[str, Any], str]:
648
+ """Gate 6: halluccheck_trust — HRI + PII + Secrets trust gate (GAT-020/021).
649
+
650
+ Delegates to :meth:`spanforge.sdk.gate.SFGateClient.run_trust_gate` if the
651
+ SDK is available, otherwise falls back to reading ``trust_gate_result.json``.
652
+ Pass conditions: hri_critical_rate < 0.05, pii_detected == false,
653
+ secrets_detected == false.
654
+
655
+ Returns (verdict, metrics, detail).
656
+ """
657
+ project_id = context.get("project", "default")
658
+ hri_threshold = cfg.extra.get("hri_critical_threshold", 0.05)
659
+
660
+ metrics: dict[str, Any] = {
661
+ "hri_critical_rate": None,
662
+ "hri_critical_threshold": hri_threshold,
663
+ "pii_detected": None,
664
+ "pii_detections_24h": 0,
665
+ "secrets_detected": None,
666
+ "secrets_detections_24h": 0,
667
+ "failures": [],
668
+ }
669
+
670
+ trust_artifact_name = cfg.artifact or "trust_gate_result.json"
671
+ artifact_dir = Path(context.get("artifact_dir", ".sf-gate/artifacts"))
672
+ trust_path = artifact_dir / trust_artifact_name
673
+
674
+ try:
675
+ # Try SDK first
676
+ from spanforge.sdk._base import SFClientConfig
677
+ from spanforge.sdk.gate import SFGateClient
678
+
679
+ gate_client = SFGateClient(SFClientConfig.from_env())
680
+ result = gate_client.run_trust_gate(project_id)
681
+ metrics["hri_critical_rate"] = result.hri_critical_rate
682
+ metrics["pii_detected"] = result.pii_detected
683
+ metrics["pii_detections_24h"] = result.pii_detections_24h
684
+ metrics["secrets_detected"] = result.secrets_detected
685
+ metrics["secrets_detections_24h"] = result.secrets_detections_24h
686
+ metrics["failures"] = result.failures
687
+
688
+ if result.pass_:
689
+ return GateVerdict.PASS, metrics, "Trust gate passed"
690
+ detail = "Trust gate FAILED: " + "; ".join(result.failures)
691
+ return GateVerdict.FAIL, metrics, detail # noqa: TRY300
692
+
693
+ except (ImportError, Exception):
694
+ pass # Fall through to artifact file
695
+
696
+ # Read trust_gate_result.json if present
697
+ if trust_path.exists():
698
+ try:
699
+ trust_data = json.loads(trust_path.read_text(encoding="utf-8"))
700
+ metrics["hri_critical_rate"] = trust_data.get("hri_critical_rate")
701
+ metrics["pii_detected"] = trust_data.get("pii_detected")
702
+ metrics["pii_detections_24h"] = trust_data.get("pii_detections_24h", 0)
703
+ metrics["secrets_detected"] = trust_data.get("secrets_detected")
704
+ metrics["secrets_detections_24h"] = trust_data.get("secrets_detections_24h", 0)
705
+ metrics["failures"] = trust_data.get("failures", [])
706
+
707
+ if trust_data.get("verdict") == "PASS":
708
+ return GateVerdict.PASS, metrics, "Trust gate passed"
709
+ return GateVerdict.FAIL, metrics, "Trust gate FAILED: " + str(metrics["failures"])
710
+ except (json.JSONDecodeError, KeyError) as exc:
711
+ return GateVerdict.ERROR, metrics, f"Could not parse trust_gate_result.json: {exc}"
712
+
713
+ return GateVerdict.WARN, metrics, "trust_gate_result.json not found; skipping trust gate check"
714
+
715
+
716
+ # ---------------------------------------------------------------------------
717
+ # Executor registry
718
+ # ---------------------------------------------------------------------------
719
+
720
+ _EXECUTOR_REGISTRY: dict[
721
+ str,
722
+ Callable[
723
+ [GateConfig, dict[str, str], int],
724
+ tuple[str, dict[str, Any], str],
725
+ ],
726
+ ] = {
727
+ "schema_validation": _exec_schema_validation,
728
+ "dependency_security": _exec_dependency_security,
729
+ "secrets_scan": _exec_secrets_scan,
730
+ "performance_regression": _exec_performance_regression,
731
+ "halluccheck_prri": _exec_halluccheck_prri,
732
+ "halluccheck_trust": _exec_halluccheck_trust,
733
+ }
734
+
735
+
736
+ def register_executor(
737
+ gate_type: str,
738
+ executor: Callable[[GateConfig, dict[str, str], int], tuple[str, dict[str, Any], str]],
739
+ ) -> None:
740
+ """Register a custom gate executor.
741
+
742
+ Args:
743
+ gate_type: The ``type`` string used in ``sf-gate.yaml``.
744
+ executor: Callable ``(GateConfig, context, timeout) -> (verdict, metrics, detail)``.
745
+
746
+ Example::
747
+
748
+ def my_executor(cfg, ctx, timeout):
749
+ return GateVerdict.PASS, {"custom_metric": 42}, "All good"
750
+
751
+ register_executor("my_custom_gate", my_executor)
752
+ """
753
+ _EXECUTOR_REGISTRY[gate_type] = executor
754
+
755
+
756
+ # ---------------------------------------------------------------------------
757
+ # YAML parser (zero-dependency)
758
+ # ---------------------------------------------------------------------------
759
+
760
+
761
+ def _parse_yaml_gates(yaml_text: str) -> list[dict[str, Any]]: # noqa: PLR0912,PLR0915
762
+ """Parse a minimal YAML gate config without PyYAML dependency.
763
+
764
+ Handles only the subset of YAML used in ``sf-gate.yaml``:
765
+ * Top-level ``gates:`` list
766
+ * String / int / bool scalar values
767
+ * Nested single-level mappings
768
+ * Simple string lists
769
+
770
+ For production use with complex configs, PyYAML is preferred. This
771
+ fallback ensures the engine works with no optional deps.
772
+ """
773
+ # Prefer PyYAML if available
774
+ try:
775
+ import yaml # type: ignore[import-untyped]
776
+
777
+ try:
778
+ data = yaml.safe_load(yaml_text)
779
+ except yaml.YAMLError:
780
+ data = None
781
+ return data.get("gates", []) if isinstance(data, dict) else []
782
+ except ImportError:
783
+ pass
784
+
785
+ # Minimal line-by-line parser
786
+ gates: list[dict[str, Any]] = []
787
+ current: dict[str, Any] | None = None
788
+ current_list_key: str | None = None
789
+ in_gates_section = False
790
+
791
+ for raw_line in yaml_text.splitlines():
792
+ line = raw_line.rstrip()
793
+ stripped = line.lstrip()
794
+
795
+ if stripped.startswith("#") or not stripped:
796
+ continue
797
+
798
+ # Detect 'gates:' section start
799
+ if re.match(r"^gates\s*:", line):
800
+ in_gates_section = True
801
+ continue
802
+
803
+ if not in_gates_section:
804
+ continue
805
+
806
+ # New gate item
807
+ if re.match(r"^\s{0,2}-\s+id\s*:", line) or (
808
+ stripped.startswith("- ") and "id:" in stripped
809
+ ):
810
+ if current is not None:
811
+ gates.append(current)
812
+ current = {}
813
+ current_list_key = None
814
+ # Parse inline key: value after '-'
815
+ inner = stripped[2:].strip()
816
+ m = re.match(r"(\w+)\s*:\s*(.*)", inner)
817
+ if m and current is not None:
818
+ current[m.group(1)] = _coerce_scalar(m.group(2).strip())
819
+ continue
820
+
821
+ if current is None:
822
+ continue
823
+
824
+ # List items
825
+ m_list_item = re.match(r"^\s{4,}-\s+(.*)", line)
826
+ if m_list_item and current_list_key:
827
+ current.setdefault(current_list_key, []).append(
828
+ _coerce_scalar(m_list_item.group(1).strip().strip('"').strip("'"))
829
+ )
830
+ continue
831
+
832
+ # Key: value pair
833
+ m_kv = re.match(r"^\s{2,4}(\w+)\s*:\s*(.*)", line)
834
+ if m_kv:
835
+ key = m_kv.group(1)
836
+ val_str = m_kv.group(2).strip()
837
+ if val_str == "":
838
+ # May be the start of a list or nested mapping
839
+ current_list_key = key
840
+ current[key] = []
841
+ else:
842
+ current_list_key = None
843
+ current[key] = _coerce_scalar(val_str.strip('"').strip("'"))
844
+ continue
845
+
846
+ if current is not None:
847
+ gates.append(current)
848
+
849
+ return gates
850
+
851
+
852
+ def _coerce_scalar(value: str) -> Any:
853
+ """Coerce a YAML scalar string to Python bool/int/float/str."""
854
+ if value.lower() == "true":
855
+ return True
856
+ if value.lower() == "false":
857
+ return False
858
+ if value.lower() in ("null", "~"):
859
+ return None
860
+ try:
861
+ return int(value)
862
+ except ValueError:
863
+ pass
864
+ try:
865
+ return float(value)
866
+ except ValueError:
867
+ pass
868
+ return value
869
+
870
+
871
+ def _dict_to_gate_config(d: dict[str, Any]) -> GateConfig:
872
+ """Convert a parsed YAML dict to a :class:`GateConfig`."""
873
+ known_keys = {
874
+ "id",
875
+ "name",
876
+ "type",
877
+ "command",
878
+ "pass_condition",
879
+ "on_fail",
880
+ "artifact",
881
+ "framework",
882
+ "timeout_seconds",
883
+ "skip_on",
884
+ "skip_on_draft",
885
+ "parallel",
886
+ }
887
+ extra = {k: v for k, v in d.items() if k not in known_keys}
888
+ pass_condition = d.get("pass_condition", {})
889
+ if not isinstance(pass_condition, dict):
890
+ pass_condition = {}
891
+
892
+ skip_on = d.get("skip_on", [])
893
+ if not isinstance(skip_on, list):
894
+ skip_on = [skip_on] if skip_on else []
895
+
896
+ return GateConfig(
897
+ id=str(d.get("id", "")),
898
+ name=str(d.get("name", d.get("id", ""))),
899
+ type=str(d.get("type", "")),
900
+ command=str(d.get("command", "")),
901
+ pass_condition=pass_condition,
902
+ on_fail=str(d.get("on_fail", "block")),
903
+ artifact=str(d.get("artifact", "")),
904
+ framework=str(d.get("framework", "")),
905
+ timeout_seconds=int(d.get("timeout_seconds", _DEFAULT_TIMEOUT_SECONDS)),
906
+ skip_on=skip_on,
907
+ skip_on_draft=bool(d.get("skip_on_draft", False)),
908
+ parallel=bool(d.get("parallel", False)),
909
+ extra=extra,
910
+ )
911
+
912
+
913
+ # ---------------------------------------------------------------------------
914
+ # Gate runner
915
+ # ---------------------------------------------------------------------------
916
+
917
+
918
+ class GateRunner:
919
+ """Executes a gate pipeline from a YAML configuration file.
920
+
921
+ Usage::
922
+
923
+ runner = GateRunner(base_dir=Path("."))
924
+ context = {
925
+ "project": "my-project",
926
+ "branch": "refs/heads/feature-x",
927
+ "commit_sha": "abc123",
928
+ "pipeline_id": "ci-42",
929
+ }
930
+ result = runner.run("examples/gates/sf-gate.yaml", context)
931
+ sys.exit(result.exit_code)
932
+
933
+ Args:
934
+ base_dir: Working directory for resolving artifact paths.
935
+ Defaults to the current directory.
936
+ is_draft: Whether the current PR/build is a draft. Affects
937
+ ``skip_on_draft`` logic.
938
+ max_workers: Maximum number of threads for parallel gate
939
+ execution. Defaults to 4.
940
+ """
941
+
942
+ def __init__(
943
+ self,
944
+ base_dir: Path | None = None,
945
+ *,
946
+ is_draft: bool = False,
947
+ max_workers: int = 4,
948
+ ) -> None:
949
+ self._base_dir = base_dir or Path.cwd()
950
+ self._is_draft = is_draft
951
+ self._max_workers = max_workers
952
+ self._store = _ArtifactStore(self._base_dir)
953
+
954
+ def run(
955
+ self,
956
+ config_path: str | Path,
957
+ context: dict[str, str] | None = None,
958
+ ) -> GateRunResult:
959
+ """Parse *config_path* and execute all gates.
960
+
961
+ Args:
962
+ config_path: Path to the YAML gate configuration file.
963
+ context: Template variable overrides. Keys used:
964
+ ``project``, ``branch``, ``commit_sha``,
965
+ ``pipeline_id``, ``timestamp``.
966
+
967
+ Returns:
968
+ :class:`GateRunResult` with the complete execution summary.
969
+ """
970
+ config_path = Path(config_path)
971
+ run_id = str(uuid.uuid4())
972
+ started_at = datetime.now(timezone.utc)
973
+ started_ts = started_at.isoformat()
974
+
975
+ # Build context with defaults
976
+ effective_context: dict[str, str] = {
977
+ "project": "",
978
+ "branch": os.environ.get("GITHUB_REF", ""),
979
+ "commit_sha": os.environ.get("GITHUB_SHA", ""),
980
+ "pipeline_id": run_id,
981
+ "timestamp": started_ts,
982
+ "artifact_dir": str(self._store._dir),
983
+ }
984
+ if context:
985
+ effective_context.update(context)
986
+
987
+ yaml_text = config_path.read_text(encoding="utf-8")
988
+ gate_dicts = _parse_yaml_gates(yaml_text)
989
+ gate_configs = [_dict_to_gate_config(d) for d in gate_dicts if d.get("id")]
990
+
991
+ results: list[GateResult] = []
992
+ parallel_cfgs = [g for g in gate_configs if g.parallel]
993
+ sequential_cfgs = [g for g in gate_configs if not g.parallel]
994
+
995
+ # Run parallel gates first (in a thread pool)
996
+ if parallel_cfgs:
997
+ parallel_results: list[GateResult | None] = [None] * len(parallel_cfgs)
998
+ threads = []
999
+ for idx, gcfg in enumerate(parallel_cfgs):
1000
+ t = threading.Thread(
1001
+ target=self._run_one,
1002
+ args=(gcfg, effective_context, parallel_results, idx),
1003
+ daemon=True,
1004
+ )
1005
+ threads.append(t)
1006
+ for t in threads[: self._max_workers]:
1007
+ t.start()
1008
+ for t in threads[: self._max_workers]:
1009
+ t.join(timeout=max(g.timeout_seconds for g in parallel_cfgs) + 5)
1010
+ results.extend(r for r in parallel_results if r is not None)
1011
+
1012
+ # Run sequential gates
1013
+ for gcfg in sequential_cfgs:
1014
+ result = self._execute_gate(gcfg, effective_context)
1015
+ results.append(result)
1016
+
1017
+ completed_at = datetime.now(timezone.utc)
1018
+ total_ms = int((completed_at - started_at).total_seconds() * 1000)
1019
+
1020
+ overall_pass = all(
1021
+ not r.is_blocking_failure(
1022
+ next(
1023
+ (c for c in gate_configs if c.id == r.gate_id),
1024
+ GateConfig(id=r.gate_id, name=r.name, type="", on_fail="block"),
1025
+ )
1026
+ )
1027
+ for r in results
1028
+ )
1029
+
1030
+ return GateRunResult(
1031
+ overall_pass=overall_pass,
1032
+ exit_code=0 if overall_pass else 1,
1033
+ gates=results,
1034
+ duration_ms=total_ms,
1035
+ run_id=run_id,
1036
+ config_path=str(config_path.resolve()),
1037
+ started_at=started_ts,
1038
+ completed_at=completed_at.isoformat(),
1039
+ )
1040
+
1041
+ def _run_one(
1042
+ self,
1043
+ cfg: GateConfig,
1044
+ context: dict[str, str],
1045
+ out: list[GateResult | None],
1046
+ idx: int,
1047
+ ) -> None:
1048
+ out[idx] = self._execute_gate(cfg, context)
1049
+
1050
+ def _execute_gate(
1051
+ self,
1052
+ cfg: GateConfig,
1053
+ context: dict[str, str],
1054
+ ) -> GateResult:
1055
+ """Execute a single gate and write its artifact."""
1056
+ started = time.monotonic()
1057
+ timestamp = datetime.now(timezone.utc).isoformat()
1058
+
1059
+ # Check skip conditions (GAT-006)
1060
+ branch = context.get("branch", "")
1061
+ if self._is_draft and cfg.skip_on_draft:
1062
+ result = GateResult(
1063
+ gate_id=cfg.id,
1064
+ name=cfg.name,
1065
+ verdict=GateVerdict.SKIPPED,
1066
+ metrics={},
1067
+ timestamp=timestamp,
1068
+ duration_ms=0,
1069
+ detail="Skipped: draft PR",
1070
+ )
1071
+ self._write_artifact(result, cfg)
1072
+ return result
1073
+
1074
+ for pattern in cfg.skip_on:
1075
+ if fnmatch.fnmatch(branch, pattern) or branch == pattern:
1076
+ result = GateResult(
1077
+ gate_id=cfg.id,
1078
+ name=cfg.name,
1079
+ verdict=GateVerdict.SKIPPED,
1080
+ metrics={},
1081
+ timestamp=timestamp,
1082
+ duration_ms=0,
1083
+ detail=f"Skipped: branch matches pattern {pattern!r}",
1084
+ )
1085
+ self._write_artifact(result, cfg)
1086
+ return result
1087
+
1088
+ # Resolve executor
1089
+ executor = _EXECUTOR_REGISTRY.get(cfg.type)
1090
+ if executor is None:
1091
+ duration_ms = int((time.monotonic() - started) * 1000)
1092
+ result = GateResult(
1093
+ gate_id=cfg.id,
1094
+ name=cfg.name,
1095
+ verdict=GateVerdict.ERROR,
1096
+ metrics={},
1097
+ timestamp=timestamp,
1098
+ duration_ms=duration_ms,
1099
+ detail=f"Unknown gate type: {cfg.type!r}",
1100
+ )
1101
+ self._write_artifact(result, cfg)
1102
+ return result
1103
+
1104
+ # Execute
1105
+ try:
1106
+ raw_verdict, metrics, detail = executor(cfg, context, cfg.timeout_seconds)
1107
+ except Exception as exc:
1108
+ _log.exception("Gate %r executor raised: %s", cfg.id, exc)
1109
+ raw_verdict = GateVerdict.ERROR
1110
+ metrics = {}
1111
+ detail = f"Executor raised: {exc}"
1112
+
1113
+ # Apply on_fail policy
1114
+ verdict = raw_verdict
1115
+ if (raw_verdict == GateVerdict.FAIL and cfg.on_fail == "warn") or (
1116
+ raw_verdict == GateVerdict.ERROR and cfg.on_fail == "warn"
1117
+ ):
1118
+ verdict = GateVerdict.WARN
1119
+
1120
+ # Evaluate pass_condition overrides if provided
1121
+ if cfg.pass_condition and raw_verdict not in (GateVerdict.SKIPPED, GateVerdict.ERROR):
1122
+ all_pass = all(
1123
+ _evaluate_pass_condition(expr, metrics.get(metric))
1124
+ for metric, expr in cfg.pass_condition.items()
1125
+ if metrics.get(metric) is not None
1126
+ )
1127
+ if not all_pass:
1128
+ verdict = GateVerdict.WARN if cfg.on_fail == "warn" else GateVerdict.FAIL
1129
+
1130
+ duration_ms = int((time.monotonic() - started) * 1000)
1131
+ result = GateResult(
1132
+ gate_id=cfg.id,
1133
+ name=cfg.name,
1134
+ verdict=verdict,
1135
+ metrics=metrics,
1136
+ timestamp=timestamp,
1137
+ duration_ms=duration_ms,
1138
+ detail=detail,
1139
+ )
1140
+ artifact_path = self._write_artifact(result, cfg)
1141
+ result.artifact_path = str(artifact_path)
1142
+ return result
1143
+
1144
+ def _write_artifact(self, result: GateResult, cfg: GateConfig) -> Path:
1145
+ """Write the gate result artifact and return its path."""
1146
+ try:
1147
+ return self._store.write(result, cfg)
1148
+ except OSError as exc:
1149
+ _log.warning("Could not write gate artifact for %r: %s", result.gate_id, exc)
1150
+ return self._store._dir / f"{result.gate_id}_result.json"