loopagent-gate 0.4.2__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.
- loopagent_gate-0.4.2.dist-info/METADATA +374 -0
- loopagent_gate-0.4.2.dist-info/RECORD +40 -0
- loopagent_gate-0.4.2.dist-info/WHEEL +5 -0
- loopagent_gate-0.4.2.dist-info/entry_points.txt +2 -0
- loopagent_gate-0.4.2.dist-info/licenses/LICENSE +21 -0
- loopagent_gate-0.4.2.dist-info/top_level.txt +1 -0
- loopgate/__init__.py +7 -0
- loopgate/__main__.py +8 -0
- loopgate/cli.py +147 -0
- loopgate/gates/anchor_witness.py +249 -0
- loopgate/gates/assert_dims.py +88 -0
- loopgate/gates/bootstrap_state.py +111 -0
- loopgate/gates/change_health.py +232 -0
- loopgate/gates/check_loop.py +1267 -0
- loopgate/gates/check_product.py +463 -0
- loopgate/gates/check_visual.py +224 -0
- loopgate/gates/doctor.py +130 -0
- loopgate/gates/estimate_task.py +137 -0
- loopgate/gates/export_public.py +289 -0
- loopgate/gates/guard_card.py +519 -0
- loopgate/gates/loop_watch.py +118 -0
- loopgate/gates/regressions.py +463 -0
- loopgate/gates/render_status_page.py +151 -0
- loopgate/gates/render_task_ledger.py +312 -0
- loopgate/gates/review_pr.py +370 -0
- loopgate/gates/sync_issues_to_kanban.py +593 -0
- loopgate/gates/tree_parity.py +307 -0
- loopgate/gates/worker_brief.py +396 -0
- loopgate/init.py +128 -0
- loopgate/paths.py +43 -0
- loopgate/py.typed +0 -0
- loopgate/templates/__init__.py +1 -0
- loopgate/templates/configs/anchor_witness.example.json +14 -0
- loopgate/templates/configs/fixtures/issues_sample.json +75 -0
- loopgate/templates/configs/fixtures/review_approve.json +5 -0
- loopgate/templates/configs/fixtures/review_changes.json +5 -0
- loopgate/templates/configs/fixtures/review_no_verdict.json +5 -0
- loopgate/templates/configs/repo_profiles.example.json +63 -0
- loopgate/templates/configs/tree_parity.example.json +16 -0
- loopgate/templates/skills/loop-engineering/SKILL.md +281 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Product acceptance gate (PLANS.md stage 7) — the user-facing criterion, kept separate from the tech gate.
|
|
3
|
+
|
|
4
|
+
Why a second gate
|
|
5
|
+
-----------------
|
|
6
|
+
``loopgate/gates/check_loop.py`` answers "is this loop disciplined?" — contracts, evidence, state, guardrails.
|
|
7
|
+
It cannot answer "can a user do their job with this product?", and merging the two lets a green technical
|
|
8
|
+
gate hide a product that does not start. The chief accepts by this gate; the tech gate stays a separate
|
|
9
|
+
verdict with a separate owner.
|
|
10
|
+
|
|
11
|
+
Contract
|
|
12
|
+
--------
|
|
13
|
+
* Every scenario in ``configs/product_scenarios.json`` states what a user/operator observes (HTTP status,
|
|
14
|
+
body marker, exit code, shutdown time) and a ``why`` line naming the user-facing consequence.
|
|
15
|
+
* A scenario whose prerequisite is missing is **SKIP with the reason**, never a silent pass; ``--strict``
|
|
16
|
+
turns SKIP into FAIL, mirroring the tech gate.
|
|
17
|
+
* Prints ``PRODUCT_ACCEPTANCE_PASS_0001`` (exit 0) or ``PRODUCT_ACCEPTANCE_STOP_0001`` (exit 1) and writes
|
|
18
|
+
a machine report.
|
|
19
|
+
* ``evaluate(scenario, observation)`` is pure: the tech gate (D11) attacks it with falsified observations
|
|
20
|
+
without starting a product process.
|
|
21
|
+
|
|
22
|
+
Usage
|
|
23
|
+
-----
|
|
24
|
+
python3 -m loopgate.gates.check_product --list
|
|
25
|
+
python3 -m loopgate.gates.check_product # normal mode: optional scenarios may SKIP
|
|
26
|
+
python3 -m loopgate.gates.check_product --strict # SKIP becomes FAIL (product acceptance)
|
|
27
|
+
python3 -m loopgate.gates.check_product --scenario P06 --verbose
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from loopgate.paths import project_root, gate_file
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import datetime as dt
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import shutil
|
|
39
|
+
import socket
|
|
40
|
+
import subprocess
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
|
|
44
|
+
ROOT = project_root()
|
|
45
|
+
PASS_MARKER = "PRODUCT_ACCEPTANCE_PASS_0001"
|
|
46
|
+
STOP_MARKER = "PRODUCT_ACCEPTANCE_STOP_0001"
|
|
47
|
+
SCENARIOS_REL = "configs/product_scenarios.json"
|
|
48
|
+
PROFILES_REL = "configs/repo_profiles.json"
|
|
49
|
+
READINESS_TIMEOUT_S = 8.0
|
|
50
|
+
DEFAULT_REPORT = "artifacts/product_acceptance_report.json"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------- config
|
|
54
|
+
|
|
55
|
+
def load_scenarios(root: str = ROOT) -> dict:
|
|
56
|
+
with open(os.path.join(root, SCENARIOS_REL), encoding="utf-8") as fh:
|
|
57
|
+
return json.load(fh)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_profile(root: str, name: str) -> dict:
|
|
61
|
+
with open(os.path.join(root, PROFILES_REL), encoding="utf-8") as fh:
|
|
62
|
+
profiles = json.load(fh)["profiles"]
|
|
63
|
+
if name not in profiles:
|
|
64
|
+
raise KeyError(f"unknown repo profile {name!r}; known: {', '.join(sorted(profiles))}")
|
|
65
|
+
return profiles[name]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def repo_path(root: str, profile: dict) -> str:
|
|
69
|
+
"""Where the product's source lives: the profile's env var, $PRODUCT_REPO_PATH, or its clone.
|
|
70
|
+
|
|
71
|
+
The env override exists so a clone can carry a profile without carrying someone else's absolute paths:
|
|
72
|
+
the shipped example declares `local_path_env` and leaves `local_path` null.
|
|
73
|
+
"""
|
|
74
|
+
env_name = profile.get("local_path_env")
|
|
75
|
+
return (os.environ.get(env_name) if env_name else None) \
|
|
76
|
+
or os.environ.get("PRODUCT_REPO_PATH") \
|
|
77
|
+
or profile.get("local_path") \
|
|
78
|
+
or ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------- evaluation (pure)
|
|
82
|
+
|
|
83
|
+
def evaluate(scenario: dict, observation: dict) -> list[str]:
|
|
84
|
+
"""Reasons the observation violates the scenario; empty list = the scenario passed.
|
|
85
|
+
|
|
86
|
+
Pure on purpose: this is what the technical gate attacks with falsified observations, so the product
|
|
87
|
+
gate cannot degrade into a script that only ever prints PASS.
|
|
88
|
+
"""
|
|
89
|
+
exp = scenario.get("expect") or {}
|
|
90
|
+
reasons: list[str] = []
|
|
91
|
+
kind = scenario.get("kind")
|
|
92
|
+
|
|
93
|
+
if kind in {"http", "signal"}:
|
|
94
|
+
if "status" in exp:
|
|
95
|
+
got = observation.get("status")
|
|
96
|
+
if got != exp["status"]:
|
|
97
|
+
reasons.append(f"expected HTTP {exp['status']}, got {got!r} on {scenario.get('path')}")
|
|
98
|
+
if exp.get("json_ok_true"):
|
|
99
|
+
body = observation.get("body") or ""
|
|
100
|
+
try:
|
|
101
|
+
payload = json.loads(body)
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
reasons.append(f"expected a JSON body with ok=true, got {body[:60]!r}")
|
|
104
|
+
else:
|
|
105
|
+
if payload.get("ok") is not True:
|
|
106
|
+
reasons.append(f"expected ok=true in the body, got {payload!r}")
|
|
107
|
+
if "body_contains" in exp and exp["body_contains"] not in (observation.get("body") or ""):
|
|
108
|
+
reasons.append(f"body does not contain {exp['body_contains']!r}")
|
|
109
|
+
if "exit_code" in exp and observation.get("exit_code") != exp["exit_code"]:
|
|
110
|
+
reasons.append(f"expected exit code {exp['exit_code']}, got {observation.get('exit_code')!r}")
|
|
111
|
+
if "max_shutdown_ms" in exp:
|
|
112
|
+
got = observation.get("shutdown_ms")
|
|
113
|
+
if got is None:
|
|
114
|
+
reasons.append("shutdown time was not measured")
|
|
115
|
+
elif got > exp["max_shutdown_ms"]:
|
|
116
|
+
reasons.append(f"shutdown took {got} ms, want at most {exp['max_shutdown_ms']} ms")
|
|
117
|
+
|
|
118
|
+
elif kind == "startup_failure":
|
|
119
|
+
code = observation.get("exit_code")
|
|
120
|
+
if exp.get("exit_nonzero") and (code == 0 or code is None):
|
|
121
|
+
reasons.append(f"expected a non-zero exit for an invalid configuration, got {code!r}")
|
|
122
|
+
needle = exp.get("stderr_contains")
|
|
123
|
+
if needle and needle not in (observation.get("stderr") or ""):
|
|
124
|
+
reasons.append(f"the error message does not name {needle!r}")
|
|
125
|
+
|
|
126
|
+
elif kind == "json_schema":
|
|
127
|
+
if observation.get("errors"):
|
|
128
|
+
reasons.append(f"the validator could not judge {len(observation['errors'])} fixture(s): "
|
|
129
|
+
f"{observation['errors'][:2]}")
|
|
130
|
+
if exp.get("valid_fixtures_validate") and observation.get("valid_failures"):
|
|
131
|
+
reasons.append(f"valid fixtures rejected: {observation['valid_failures'][:3]}")
|
|
132
|
+
if exp.get("invalid_fixtures_rejected") and observation.get("invalid_accepted"):
|
|
133
|
+
reasons.append(f"invalid fixtures accepted: {observation['invalid_accepted'][:3]}")
|
|
134
|
+
|
|
135
|
+
else:
|
|
136
|
+
reasons.append(f"unknown scenario kind {kind!r}")
|
|
137
|
+
|
|
138
|
+
return reasons
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------- process control
|
|
142
|
+
|
|
143
|
+
def free_port() -> int:
|
|
144
|
+
with socket.socket() as sock:
|
|
145
|
+
sock.bind(("127.0.0.1", 0))
|
|
146
|
+
return int(sock.getsockname()[1])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def port_env(scenario: dict, ports: dict) -> dict:
|
|
150
|
+
return dict(ports)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def build_product(path: str) -> tuple[bool, str]:
|
|
154
|
+
"""Build the product binary if it is missing. Returns (ok, note)."""
|
|
155
|
+
binary = os.path.join(path, "bin", "sample-service")
|
|
156
|
+
if os.path.exists(binary):
|
|
157
|
+
return True, f"using {os.path.relpath(binary, path)}"
|
|
158
|
+
os.makedirs(os.path.dirname(binary), exist_ok=True)
|
|
159
|
+
proc = subprocess.run(["go", "build", "-mod=vendor", "-o", binary, "./cmd"],
|
|
160
|
+
cwd=path, capture_output=True, text=True)
|
|
161
|
+
if proc.returncode != 0:
|
|
162
|
+
return False, f"go build failed: {proc.stderr.strip()[:200]}"
|
|
163
|
+
return True, "built bin/sample-service"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def start_process(path: str, env: dict, ports: dict) -> subprocess.Popen:
|
|
167
|
+
full_env = {k: str(v) for k, v in env.items()}
|
|
168
|
+
# Ports come from the caller unless the scenario deliberately sets one (that is how the
|
|
169
|
+
# startup_failure scenario injects an invalid value instead of a free port).
|
|
170
|
+
full_env.setdefault("HTTP_SERVER_LISTEN_PORT", str(ports["api"]))
|
|
171
|
+
full_env.setdefault("HTTP_SERVER_PROBES_PORT", str(ports["probes"]))
|
|
172
|
+
full_env.setdefault("HTTP_SERVER_METRICS_PORT", str(ports["metrics"]))
|
|
173
|
+
full_env.setdefault("PATH", os.environ.get("PATH", "/usr/bin:/bin"))
|
|
174
|
+
return subprocess.Popen([os.path.join(path, "bin", "sample-service")], cwd=path, env=full_env,
|
|
175
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def wait_for_port(port: int, proc: subprocess.Popen, timeout: float = READINESS_TIMEOUT_S) -> bool:
|
|
179
|
+
deadline = time.time() + timeout
|
|
180
|
+
while time.time() < deadline:
|
|
181
|
+
if proc.poll() is not None:
|
|
182
|
+
return False
|
|
183
|
+
try:
|
|
184
|
+
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
|
|
185
|
+
return True
|
|
186
|
+
except OSError:
|
|
187
|
+
time.sleep(0.15)
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def http_get(port: int, path: str, timeout: float = 4.0) -> dict:
|
|
192
|
+
import urllib.error
|
|
193
|
+
import urllib.request
|
|
194
|
+
url = f"http://127.0.0.1:{port}{path}"
|
|
195
|
+
try:
|
|
196
|
+
with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 - localhost only
|
|
197
|
+
return {"status": resp.status, "body": resp.read().decode("utf-8", "replace")}
|
|
198
|
+
except urllib.error.HTTPError as exc:
|
|
199
|
+
return {"status": exc.code, "body": exc.read().decode("utf-8", "replace")}
|
|
200
|
+
except Exception as exc: # noqa: BLE001 - a refused connection is an observation, not a crash
|
|
201
|
+
return {"status": None, "error": f"{exc.__class__.__name__}: {exc}"}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------- runners
|
|
205
|
+
|
|
206
|
+
def run_http_group(scenarios: list[dict], path: str, env: dict, verbose: bool) -> dict[str, dict]:
|
|
207
|
+
"""Start the product once and observe every http/signal scenario against that process."""
|
|
208
|
+
ports = {"api": free_port(), "probes": free_port(), "metrics": free_port()}
|
|
209
|
+
proc = start_process(path, env, ports)
|
|
210
|
+
observations: dict[str, dict] = {}
|
|
211
|
+
try:
|
|
212
|
+
ready_port = ports["probes"]
|
|
213
|
+
if not wait_for_port(ready_port, proc):
|
|
214
|
+
note = f"the process did not bind the probes port within {READINESS_TIMEOUT_S:.0f}s"
|
|
215
|
+
for sc in scenarios:
|
|
216
|
+
observations[sc["id"]] = {"startup_failed": note, "status": None, "exit_code": proc.poll()}
|
|
217
|
+
return observations
|
|
218
|
+
for sc in scenarios:
|
|
219
|
+
if sc["kind"] == "http":
|
|
220
|
+
observations[sc["id"]] = http_get(ports[sc["port"]], sc["path"])
|
|
221
|
+
if verbose:
|
|
222
|
+
print(f" {sc['id']} {sc['path']} -> {observations[sc['id']].get('status')}")
|
|
223
|
+
elif sc["kind"] == "signal":
|
|
224
|
+
started = time.time()
|
|
225
|
+
proc.terminate()
|
|
226
|
+
try:
|
|
227
|
+
code = proc.wait(timeout=5)
|
|
228
|
+
except subprocess.TimeoutExpired:
|
|
229
|
+
proc.kill()
|
|
230
|
+
code = proc.wait(timeout=5)
|
|
231
|
+
observations[sc["id"]] = {"exit_code": code,
|
|
232
|
+
"shutdown_ms": round((time.time() - started) * 1000)}
|
|
233
|
+
finally:
|
|
234
|
+
if proc.poll() is None:
|
|
235
|
+
proc.kill()
|
|
236
|
+
proc.wait(timeout=5)
|
|
237
|
+
return observations
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def run_startup_failure(scenario: dict, path: str, env: dict, verbose: bool) -> dict:
|
|
241
|
+
merged = dict(env)
|
|
242
|
+
merged.update(scenario.get("env") or {})
|
|
243
|
+
ports = {"api": free_port(), "probes": free_port(), "metrics": free_port()}
|
|
244
|
+
proc = start_process(path, merged, ports)
|
|
245
|
+
try:
|
|
246
|
+
code = proc.wait(timeout=10)
|
|
247
|
+
except subprocess.TimeoutExpired:
|
|
248
|
+
proc.kill()
|
|
249
|
+
code = None
|
|
250
|
+
stderr = (proc.stderr.read() if proc.stderr else "") or ""
|
|
251
|
+
if verbose:
|
|
252
|
+
print(f" {scenario['id']} exit={code} stderr={stderr.strip()[:120]}")
|
|
253
|
+
return {"exit_code": code, "stderr": stderr}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def run_json_schema(scenario: dict, path: str, verbose: bool) -> dict:
|
|
257
|
+
"""Validate the published contracts against their own fixtures.
|
|
258
|
+
|
|
259
|
+
The v1 fixtures are *bundles* (context, plan, result, manifest, worker_events, integration_events), so a
|
|
260
|
+
fixture is only meaningfully judged when each member is validated against its own published schema — the
|
|
261
|
+
version pins live in those member schemas as ``const``, which is exactly what the invalid fixtures break.
|
|
262
|
+
"""
|
|
263
|
+
try:
|
|
264
|
+
import jsonschema
|
|
265
|
+
import referencing
|
|
266
|
+
import referencing.jsonschema
|
|
267
|
+
except ImportError as exc:
|
|
268
|
+
# "Not installed" and "installed but broken" are different problems with different fixes, and
|
|
269
|
+
# conflating them sends the reader looking for a package that is already there (rpds-py ships as
|
|
270
|
+
# a binary wheel; a damaged install raises ModuleNotFoundError from inside jsonschema itself).
|
|
271
|
+
detail = str(exc)
|
|
272
|
+
if "rpds" in detail or "referencing" in detail:
|
|
273
|
+
return {"skip": f"python:jsonschema is installed but damaged ({detail}); repair with "
|
|
274
|
+
"`python3 -m pip install --force-reinstall rpds-py referencing`"}
|
|
275
|
+
return {"skip": f"python:jsonschema is not installed ({detail})"}
|
|
276
|
+
|
|
277
|
+
schema_dir = os.path.join(path, os.path.dirname(scenario["schema"]))
|
|
278
|
+
schemas, resources = {}, []
|
|
279
|
+
for name in sorted(os.listdir(schema_dir)):
|
|
280
|
+
if not name.endswith(".schema.json"):
|
|
281
|
+
continue
|
|
282
|
+
doc = json.load(open(os.path.join(schema_dir, name), encoding="utf-8"))
|
|
283
|
+
schemas[name] = doc
|
|
284
|
+
for uri in filter(None, (name, doc.get("$id"))):
|
|
285
|
+
resources.append((uri, referencing.Resource.from_contents(
|
|
286
|
+
doc, default_specification=referencing.jsonschema.DRAFT202012)))
|
|
287
|
+
registry = referencing.Registry().with_resources(resources)
|
|
288
|
+
members = scenario.get("members") or {}
|
|
289
|
+
|
|
290
|
+
valid_failures, invalid_accepted, errors = [], [], []
|
|
291
|
+
for folder, should_pass in ((scenario["fixtures_valid"], True), (scenario["fixtures_invalid"], False)):
|
|
292
|
+
folder_path = os.path.join(path, folder)
|
|
293
|
+
names = sorted(n for n in os.listdir(folder_path) if n.endswith(".json")) \
|
|
294
|
+
if os.path.isdir(folder_path) else []
|
|
295
|
+
for name in names:
|
|
296
|
+
bundle = json.load(open(os.path.join(folder_path, name), encoding="utf-8"))
|
|
297
|
+
rejected: list[str] = []
|
|
298
|
+
for member, schema_name in members.items():
|
|
299
|
+
is_list = member.endswith("[]")
|
|
300
|
+
key = member[:-2] if is_list else member
|
|
301
|
+
value = bundle.get(key)
|
|
302
|
+
if value is None:
|
|
303
|
+
continue
|
|
304
|
+
items = value if is_list and isinstance(value, list) else [value]
|
|
305
|
+
for index, item in enumerate(items):
|
|
306
|
+
if not isinstance(item, dict):
|
|
307
|
+
continue
|
|
308
|
+
where = f"{member}[{index}]" if is_list else member
|
|
309
|
+
try:
|
|
310
|
+
jsonschema.validate(item, schemas[schema_name], registry=registry)
|
|
311
|
+
except jsonschema.ValidationError as exc:
|
|
312
|
+
rejected.append(f"{where}: {exc.message[:70]}")
|
|
313
|
+
except Exception as exc: # noqa: BLE001 - a resolver error is a failed check
|
|
314
|
+
errors.append(f"{name}:{where}: {exc.__class__.__name__}: {str(exc)[:70]}")
|
|
315
|
+
if verbose:
|
|
316
|
+
print(f" {scenario['id']} {folder}/{name} rejected_members={len(rejected)} "
|
|
317
|
+
f"(want {'0' if should_pass else '>0'})")
|
|
318
|
+
if should_pass and rejected:
|
|
319
|
+
valid_failures.append(f"{name} ({rejected[0]})")
|
|
320
|
+
if not should_pass and not rejected:
|
|
321
|
+
invalid_accepted.append(name)
|
|
322
|
+
return {"valid_failures": valid_failures, "invalid_accepted": invalid_accepted, "errors": errors}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------- main
|
|
326
|
+
|
|
327
|
+
def main(argv=None) -> int:
|
|
328
|
+
ap = argparse.ArgumentParser(description="Product acceptance gate (user-facing scenarios).")
|
|
329
|
+
ap.add_argument("--root", default=ROOT)
|
|
330
|
+
ap.add_argument("--repo-path", help="path to the product source (default: the profile's local_path)")
|
|
331
|
+
ap.add_argument("--list", action="store_true", help="print the scenario inventory and exit")
|
|
332
|
+
ap.add_argument("--scenario", action="append", help="run only these scenario ids (repeatable)")
|
|
333
|
+
ap.add_argument("--strict", action="store_true", help="treat SKIP as FAIL (product acceptance)")
|
|
334
|
+
ap.add_argument("--report", default=None)
|
|
335
|
+
ap.add_argument("--json", action="store_true")
|
|
336
|
+
ap.add_argument("--verbose", action="store_true")
|
|
337
|
+
args = ap.parse_args(argv)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
config = load_scenarios(args.root)
|
|
341
|
+
profile = load_profile(args.root, config["profile"])
|
|
342
|
+
except (OSError, KeyError, json.JSONDecodeError) as exc:
|
|
343
|
+
print(f"config error: {exc}", file=sys.stderr)
|
|
344
|
+
print(STOP_MARKER)
|
|
345
|
+
return 2
|
|
346
|
+
|
|
347
|
+
scenarios = config["scenarios"]
|
|
348
|
+
if args.list:
|
|
349
|
+
print(f"product: {config['product']} ({len(scenarios)} scenarios, "
|
|
350
|
+
f"{sum(1 for s in scenarios if s.get('optional'))} optional)")
|
|
351
|
+
for sc in scenarios:
|
|
352
|
+
flag = "optional" if sc.get("optional") else "mandatory"
|
|
353
|
+
print(f" {sc['id']} [{flag}] {sc['kind']:<16} {sc['title']}")
|
|
354
|
+
return 0
|
|
355
|
+
|
|
356
|
+
if args.scenario:
|
|
357
|
+
wanted = set(args.scenario)
|
|
358
|
+
scenarios = [s for s in scenarios if s["id"] in wanted]
|
|
359
|
+
if not scenarios:
|
|
360
|
+
print(f"no scenario matches {sorted(wanted)}", file=sys.stderr)
|
|
361
|
+
return 2
|
|
362
|
+
|
|
363
|
+
path = args.repo_path or repo_path(args.root, profile)
|
|
364
|
+
print("PRODUCT ACCEPTANCE GATE — loopgate/gates/check_product.py")
|
|
365
|
+
print(f"product: {config['product']} source: {path or '(not configured)'}")
|
|
366
|
+
print(f"mode: {'strict' if args.strict else 'normal'}")
|
|
367
|
+
print()
|
|
368
|
+
|
|
369
|
+
if not path or not os.path.isdir(path):
|
|
370
|
+
print(f"[FAIL] the product source is missing: {path!r} "
|
|
371
|
+
f"(set local_path in {PROFILES_REL} or pass --repo-path)")
|
|
372
|
+
print(STOP_MARKER)
|
|
373
|
+
return 1
|
|
374
|
+
|
|
375
|
+
ok, note = build_product(path)
|
|
376
|
+
print(f"build: {note}")
|
|
377
|
+
if not ok:
|
|
378
|
+
print(f"[FAIL] {note}")
|
|
379
|
+
print(STOP_MARKER)
|
|
380
|
+
return 1
|
|
381
|
+
print()
|
|
382
|
+
|
|
383
|
+
results: list[dict] = []
|
|
384
|
+
http_group = [s for s in scenarios if s["kind"] in {"http", "signal"}]
|
|
385
|
+
observations: dict[str, dict] = {}
|
|
386
|
+
if http_group:
|
|
387
|
+
observations.update(run_http_group(http_group, path, config["env_base"], args.verbose))
|
|
388
|
+
|
|
389
|
+
for sc in scenarios:
|
|
390
|
+
if sc["kind"] in {"http", "signal"}:
|
|
391
|
+
obs = observations.get(sc["id"], {})
|
|
392
|
+
elif sc["kind"] == "startup_failure":
|
|
393
|
+
obs = run_startup_failure(sc, path, config["env_base"], args.verbose)
|
|
394
|
+
elif sc["kind"] == "json_schema":
|
|
395
|
+
obs = run_json_schema(sc, path, args.verbose)
|
|
396
|
+
else:
|
|
397
|
+
obs = {"skip": f"unknown kind {sc['kind']!r}"}
|
|
398
|
+
|
|
399
|
+
skip_reason = obs.get("skip")
|
|
400
|
+
if not skip_reason and obs.get("startup_failed"):
|
|
401
|
+
skip_reason = None # a product that does not start is a FAIL, not a skip
|
|
402
|
+
reasons = [] if skip_reason else evaluate(sc, obs)
|
|
403
|
+
if not reasons and obs.get("startup_failed"):
|
|
404
|
+
reasons = [obs["startup_failed"]]
|
|
405
|
+
status = "SKIP" if skip_reason else ("OK" if not reasons else "FAIL")
|
|
406
|
+
if skip_reason and args.strict:
|
|
407
|
+
status = "FAIL"
|
|
408
|
+
reasons = [f"prerequisite missing: {skip_reason}"]
|
|
409
|
+
results.append({"id": sc["id"], "title": sc["title"], "kind": sc["kind"],
|
|
410
|
+
"status": status, "reasons": reasons, "observation": obs,
|
|
411
|
+
"optional": bool(sc.get("optional"))})
|
|
412
|
+
print(f"{sc['id']} [{status}] {sc['title']}")
|
|
413
|
+
if skip_reason:
|
|
414
|
+
print(f" - SKIP: {skip_reason}")
|
|
415
|
+
for reason in reasons:
|
|
416
|
+
print(f" - {reason}")
|
|
417
|
+
|
|
418
|
+
passed = sum(1 for r in results if r["status"] == "OK")
|
|
419
|
+
failed = [r for r in results if r["status"] == "FAIL"]
|
|
420
|
+
skipped = [r for r in results if r["status"] == "SKIP"]
|
|
421
|
+
ok = not failed
|
|
422
|
+
print()
|
|
423
|
+
print(f"scenarios: {passed} ok, {len(failed)} fail, {len(skipped)} skip "
|
|
424
|
+
f"(mandatory: {sum(1 for s in scenarios if not s.get('optional'))}, "
|
|
425
|
+
f"optional: {sum(1 for s in scenarios if s.get('optional'))})")
|
|
426
|
+
print(f"verdict: {'product accepted' if ok else 'product NOT accepted'}")
|
|
427
|
+
|
|
428
|
+
report = {
|
|
429
|
+
"marker": PASS_MARKER if ok else STOP_MARKER,
|
|
430
|
+
"product": config["product"],
|
|
431
|
+
"source": path,
|
|
432
|
+
"strict": args.strict,
|
|
433
|
+
"generated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
434
|
+
"scenarios": [{k: v for k, v in r.items() if k != "observation"} | {"observation": r["observation"]}
|
|
435
|
+
for r in results],
|
|
436
|
+
"failures": [r["id"] for r in failed],
|
|
437
|
+
"skips": [r["id"] for r in skipped],
|
|
438
|
+
}
|
|
439
|
+
report_path = args.report or config.get("report") or DEFAULT_REPORT
|
|
440
|
+
if not os.path.isabs(report_path):
|
|
441
|
+
report_path = os.path.join(args.root, report_path)
|
|
442
|
+
try:
|
|
443
|
+
os.makedirs(os.path.dirname(report_path), exist_ok=True)
|
|
444
|
+
with open(report_path, "w", encoding="utf-8") as fh:
|
|
445
|
+
json.dump(report, fh, ensure_ascii=False, indent=2)
|
|
446
|
+
fh.write("\n")
|
|
447
|
+
print(f"report: {os.path.relpath(report_path, args.root)}")
|
|
448
|
+
except OSError as exc:
|
|
449
|
+
print(f"warning: could not write the report ({exc.__class__.__name__}: {exc}); verdict unchanged")
|
|
450
|
+
if args.json:
|
|
451
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
452
|
+
if ok:
|
|
453
|
+
print(PASS_MARKER)
|
|
454
|
+
return 0
|
|
455
|
+
print(f"FAILURES ({len(failed)}):")
|
|
456
|
+
for i, r in enumerate(failed, 1):
|
|
457
|
+
print(f" {i}. {r['id']} {r['title']}: {r['reasons'][0] if r['reasons'] else 'see report'}")
|
|
458
|
+
print(STOP_MARKER)
|
|
459
|
+
return 1
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
if __name__ == "__main__":
|
|
463
|
+
sys.exit(main())
|