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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- 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"
|